OCI Queue Service: Building Decoupled Microservices with Terraform

Synchronous API calls between microservices create tight coupling. Service A calls Service B and waits. If Service B is slow, Service A is slow. If Service B is down, Service A fails. If traffic spikes, both services need to scale simultaneously or requests start timing out. This is a fragile architecture for anything beyond simple workloads.

Message queues decouple producers from consumers. Service A puts a message in a queue and moves on. Service B reads from the queue at its own pace. If Service B is temporarily unavailable, messages accumulate in the queue and get processed when it comes back. If traffic spikes, the queue absorbs the burst and consumers process it without the producer being affected.

OCI Queue is Oracle’s managed message queue service. It is HTTP-based, requires no client libraries beyond a standard HTTP client, supports at-least-once delivery, and integrates natively with OCI IAM, Monitoring, and Functions. This post covers deploying OCI Queue with Terraform, building producers and consumers in Python, configuring dead letter queues, and handling visibility timeouts correctly.

Architecture

Order Service (producer)
        |
   OCI Queue (orders-processing-queue)
        |
   -------------------------
   |                       |
Notification           Inventory
Consumer               Consumer
        |                   |
  OCI Notifications    OCI Autonomous DB
        |
 Dead Letter Queue
(failed messages - manual review)

Step 1: IAM Policy

resource "oci_identity_dynamic_group" "queue_consumers_dg" {
  compartment_id = var.tenancy_ocid
  name           = "queue-consumer-instances"
  description    = "Compute instances and Functions that consume from OCI Queue"
  matching_rule  = "Any {instance.compartment.id = '${var.compartment_id}', resource.type = 'fnfunc'}"
}

resource "oci_identity_policy" "queue_policy" {
  compartment_id = var.compartment_id
  name           = "oci-queue-policy"
  description    = "Permissions for OCI Queue producers and consumers"

  statements = [
    # Producers can put messages
    "Allow group ${var.producer_group_name} to use queues in compartment id ${var.compartment_id}",

    # Consumer instances can get, delete, and update messages
    "Allow dynamic-group queue-consumer-instances to use queues in compartment id ${var.compartment_id}",
    "Allow dynamic-group queue-consumer-instances to read queues in compartment id ${var.compartment_id}"
  ]
}

Step 2: Provision the Queue and Dead Letter Queue

# Dead Letter Queue - receives messages that fail processing repeatedly
resource "oci_queue_queue" "orders_dlq" {
  compartment_id                 = var.compartment_id
  display_name                   = "orders-processing-dlq"
  visibility_in_seconds          = 30
  timeout_in_seconds             = 30
  retention_in_seconds           = 604800  # 7 days
  max_in_flight_messages         = 100

  defined_tags = {
    "Operations.Environment" = "production"
    "Operations.Purpose"     = "dead-letter-queue"
    "Operations.ManagedBy"   = "terraform"
  }
}

# Main processing queue
resource "oci_queue_queue" "orders_queue" {
  compartment_id                 = var.compartment_id
  display_name                   = "orders-processing-queue"
  visibility_in_seconds          = 60
  timeout_in_seconds             = 30
  retention_in_seconds           = 86400   # 24 hours
  max_in_flight_messages         = 500

  # After 3 failed delivery attempts, move to DLQ
  dead_letter_queue_delivery_count = 3
  custom_encryption_key_id         = var.vault_key_id

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

output "queue_endpoint" {
  value       = oci_queue_queue.orders_queue.messages_endpoint
  description = "HTTP endpoint for producing and consuming messages"
}

output "queue_id" {
  value = oci_queue_queue.orders_queue.id
}

The visibility_in_seconds = 60 setting is critical. When a consumer reads a message, OCI Queue hides that message from other consumers for 60 seconds. If the consumer processes the message successfully and deletes it within that window, it is gone. If the consumer crashes or fails to delete the message, OCI Queue makes it visible again after 60 seconds for another consumer to pick up. Set this to comfortably exceed your maximum expected processing time for a single message.

The dead_letter_queue_delivery_count = 3 means after three failed visibility timeout expirations for the same message, OCI Queue moves it to the DLQ automatically. This prevents poison pill messages from blocking your queue forever.

Step 3: Producer Implementation

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

logger = logging.getLogger(__name__)

class OrderQueueProducer:
    def __init__(self, queue_id: str, messages_endpoint: str):
        self.queue_id         = queue_id
        self.messages_endpoint = messages_endpoint

        config = oci.config.from_file()
        self.client = oci.queue.QueueClient(
            config,
            service_endpoint=messages_endpoint
        )

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

        put_details = oci.queue.models.PutMessagesDetails(
            messages=[
                oci.queue.models.PutMessagesDetailsEntry(
                    content=base64.b64encode(message_body.encode()).decode()
                )
            ]
        )

        response = self.client.put_messages(
            queue_id=self.queue_id,
            put_messages_details=put_details
        )

        message_id = response.data.messages[0].id
        logger.info(f"Published {event_type} for order {order_id}, message_id: {message_id}")
        return message_id

    def publish_batch(self, events: List[dict]) -> List[str]:
        if len(events) > 20:
            raise ValueError("OCI Queue supports maximum 20 messages per batch")

        entries = [
            oci.queue.models.PutMessagesDetailsEntry(
                content=base64.b64encode(
                    json.dumps(event).encode()
                ).decode()
            )
            for event in events
        ]

        put_details = oci.queue.models.PutMessagesDetails(messages=entries)
        response = self.client.put_messages(
            queue_id=self.queue_id,
            put_messages_details=put_details
        )

        message_ids = [msg.id for msg in response.data.messages]
        logger.info(f"Published batch of {len(message_ids)} messages")
        return message_ids

Step 4: Consumer Implementation with Visibility Timeout Renewal

The consumer reads messages, processes them, and deletes them. For long-running processing tasks, the consumer must renew the visibility timeout before it expires, otherwise OCI Queue makes the message visible again and another consumer picks it up, causing duplicate processing.

import oci
import json
import base64
import logging
import time
import threading
from typing import Callable, Optional

logger = logging.getLogger(__name__)

class OrderQueueConsumer:
    def __init__(
        self,
        queue_id: str,
        messages_endpoint: str,
        visibility_in_seconds: int = 60
    ):
        self.queue_id              = queue_id
        self.messages_endpoint     = messages_endpoint
        self.visibility_in_seconds = visibility_in_seconds
        self.running               = False

        config = oci.config.from_file()
        self.client = oci.queue.QueueClient(
            config,
            service_endpoint=messages_endpoint
        )

    def _renew_visibility(self, receipt: str, stop_event: threading.Event):
        renewal_interval = self.visibility_in_seconds * 0.7
        while not stop_event.wait(timeout=renewal_interval):
            try:
                self.client.update_message(
                    queue_id=self.queue_id,
                    message_receipt=receipt,
                    update_message_details=oci.queue.models.UpdateMessageDetails(
                        visibility_in_seconds=self.visibility_in_seconds
                    )
                )
                logger.debug(f"Renewed visibility for receipt {receipt[:20]}...")
            except Exception as e:
                logger.warning(f"Failed to renew visibility: {e}")
                break

    def process_message(self, message, handler: Callable) -> bool:
        content = json.loads(base64.b64decode(message.content).decode())
        receipt = message.receipt

        stop_renewal = threading.Event()
        renewal_thread = threading.Thread(
            target=self._renew_visibility,
            args=(receipt, stop_renewal),
            daemon=True
        )
        renewal_thread.start()

        try:
            handler(content)

            self.client.delete_message(
                queue_id=self.queue_id,
                message_receipt=receipt
            )
            logger.info(f"Processed and deleted message, event: {content.get('event_type')}")
            return True

        except Exception as e:
            logger.error(f"Failed to process message: {e}. Message will become visible again.")
            return False

        finally:
            stop_renewal.set()
            renewal_thread.join(timeout=2)

    def consume(self, handler: Callable, max_messages: int = 10):
        self.running = True
        logger.info(f"Starting consumer for queue {self.queue_id}")

        while self.running:
            try:
                response = self.client.get_messages(
                    queue_id=self.queue_id,
                    limit=max_messages,
                    visibility_in_seconds=self.visibility_in_seconds,
                    timeout_in_seconds=20  # long polling
                )

                messages = response.data.messages
                if not messages:
                    continue

                logger.info(f"Received {len(messages)} messages")
                for message in messages:
                    self.process_message(message, handler)

            except oci.exceptions.ServiceError as e:
                logger.error(f"OCI Queue service error: {e}")
                time.sleep(5)

            except KeyboardInterrupt:
                logger.info("Consumer shutting down")
                self.running = False

    def stop(self):
        self.running = False

Step 5: The Message Handler

def handle_order_event(event: dict):
    event_type = event.get("event_type")
    order_id   = event.get("order_id")
    payload    = event.get("payload", {})

    logger.info(f"Processing {event_type} for order {order_id}")

    if event_type == "ORDER_PLACED":
        _handle_order_placed(order_id, payload)

    elif event_type == "ORDER_PAID":
        _handle_order_paid(order_id, payload)

    elif event_type == "ORDER_CANCELLED":
        _handle_order_cancelled(order_id, payload)

    else:
        logger.warning(f"Unknown event type: {event_type}. Skipping.")

def _handle_order_placed(order_id: str, payload: dict):
    # Reserve inventory
    items = payload.get("items", [])
    for item in items:
        reserve_inventory(item["sku"], item["quantity"])

    # Send confirmation notification
    send_notification(
        customer_id=payload["customer_id"],
        template="order_confirmation",
        data={"order_id": order_id, "items": items}
    )

def _handle_order_paid(order_id: str, payload: dict):
    update_order_status(order_id, "PROCESSING")
    trigger_fulfillment(order_id, payload["shipping_address"])

def _handle_order_cancelled(order_id: str, payload: dict):
    release_inventory(order_id)
    if payload.get("refund_required"):
        initiate_refund(order_id, payload["payment_reference"])


if __name__ == "__main__":
    import os
    consumer = OrderQueueConsumer(
        queue_id=os.environ["QUEUE_ID"],
        messages_endpoint=os.environ["QUEUE_ENDPOINT"],
        visibility_in_seconds=60
    )
    consumer.consume(handler=handle_order_event, max_messages=10)

Step 6: Dead Letter Queue Monitor

Messages in the DLQ represent processing failures that need investigation. Set up a monitoring alarm so the team is notified when messages land in the DLQ, and build a separate process to inspect them.

resource "oci_monitoring_alarm" "dlq_messages_alarm" {
  compartment_id        = var.compartment_id
  display_name          = "orders-dlq-has-messages"
  is_enabled            = true
  metric_compartment_id = var.compartment_id
  namespace             = "oci_queue"
  query                 = "VisibleMessages[5m]{queueId = '${oci_queue_queue.orders_dlq.id}'}.max() > 0"
  severity              = "CRITICAL"
  pending_duration      = "PT5M"
  destinations          = [var.ops_notification_topic_id]
  body                  = "Messages have arrived in the orders DLQ. These represent failed processing attempts that need manual investigation."
}

resource "oci_monitoring_alarm" "queue_depth_alarm" {
  compartment_id        = var.compartment_id
  display_name          = "orders-queue-depth-high"
  is_enabled            = true
  metric_compartment_id = var.compartment_id
  namespace             = "oci_queue"
  query                 = "VisibleMessages[5m]{queueId = '${oci_queue_queue.orders_queue.id}'}.max() > 1000"
  severity              = "WARNING"
  pending_duration      = "PT10M"
  destinations          = [var.ops_notification_topic_id]
  body                  = "Orders queue depth exceeded 1000 messages. Consumers may be falling behind. Consider scaling up consumer instances."
}

To inspect DLQ messages without deleting them, read with a long visibility timeout, log the content for investigation, and then choose whether to reprocess or discard each one:

def inspect_dlq(dlq_id: str, messages_endpoint: str):
    config = oci.config.from_file()
    client = oci.queue.QueueClient(config, service_endpoint=messages_endpoint)

    response = client.get_messages(
        queue_id=dlq_id,
        limit=10,
        visibility_in_seconds=300,  # 5 minutes to inspect before it becomes visible again
        timeout_in_seconds=5
    )

    for msg in response.data.messages:
        content = json.loads(base64.b64decode(msg.content).decode())
        logger.error(
            f"DLQ message - "
            f"event_type: {content.get('event_type')}, "
            f"order_id: {content.get('order_id')}, "
            f"receipt: {msg.receipt[:30]}..."
        )

    return response.data.messages

Step 7: Deploying Consumers on OKE

Run queue consumers as Kubernetes Deployments on OKE. Each pod runs one consumer process. Scale the deployment based on queue depth using the OCI Queue metrics exposed to the Kubernetes HPA through a custom metrics adapter.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders-queue-consumer
  namespace: orders
spec:
  replicas: 3
  selector:
    matchLabels:
      app: orders-queue-consumer
  template:
    metadata:
      labels:
        app: orders-queue-consumer
    spec:
      serviceAccountName: queue-consumer-sa
      containers:
        - name: consumer
          image: <region>.ocir.io/<namespace>/orders-consumer:latest
          env:
            - name: QUEUE_ID
              valueFrom:
                secretKeyRef:
                  name: queue-config
                  key: queue-id
            - name: QUEUE_ENDPOINT
              valueFrom:
                secretKeyRef:
                  name: queue-config
                  key: messages-endpoint
          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          livenessProbe:
            exec:
              command: ["python", "-c", "import os; os.kill(1, 0)"]
            initialDelaySeconds: 30
            periodSeconds: 30

Key Operational Considerations

Idempotency is mandatory. OCI Queue guarantees at-least-once delivery, which means the same message can be delivered more than once. Your handler must be idempotent: processing the same order event twice should produce the same result as processing it once. Use the order_id and event_type combination as an idempotency key and check a processed-events store before executing any state-changing operation.

Message ordering is not guaranteed within a single queue. If your use case requires strict ordering of events for the same order, include a sequence number in the message payload and handle out-of-order delivery in the consumer logic.

The visibility timeout must be longer than your maximum processing time. If processing occasionally takes 45 seconds, set the visibility timeout to at least 90 seconds, not 60. A message that expires its visibility timeout before deletion gets reprocessed, which triggers your idempotency logic and wastes consumer capacity.

Long polling reduces empty receive calls. Setting timeout_in_seconds=20 in the get_messages call tells OCI Queue to wait up to 20 seconds for a message before returning an empty response. Without long polling, a quiet queue generates a constant stream of empty API calls consuming CPU and generating unnecessary network traffic.

Regards,
Osama

#OCI #OracleCloud #Microservices #MessageQueue #Terraform #CloudNative #InfrastructureAsCode #IaC #OracleCloudInfrastructure #PlatformEngineering #CloudArchitecture #DevOps #DistributedSystems #BackendEngineering #TechBlog #AsyncMessaging #CloudEngineering #Oracle #OCIQueue #EventDriven

Leave a comment

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