| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| |
| import argparse |
| import asyncio |
| import json |
| import secrets |
| import time |
| from collections.abc import Callable, Iterator |
| from dataclasses import dataclass |
| from enum import Enum |
| |
| from apache_iggy import ( |
| HeaderKey, |
| HeaderValue, |
| IggyClient, |
| PollingStrategy, |
| ReceiveMessage, |
| StreamDetails, |
| TopicDetails, |
| ) |
| from apache_iggy import SendMessage as Message |
| from loguru import logger |
| |
| STREAM_NAME = "message-headers-stream" |
| TOPIC_NAME = "orders" |
| PARTITION_ID = 0 |
| BATCHES_LIMIT = 5 |
| MESSAGES_PER_BATCH = 10 |
| |
| PlainHeaderValue = str | bytes | bool | int | float |
| PlainHeaders = dict[str, PlainHeaderValue] |
| TypedHeaders = dict[HeaderKey, HeaderValue] |
| HeadersBuilder = Callable[["Order"], PlainHeaders | TypedHeaders] |
| MessageHandler = Callable[[ReceiveMessage], None] |
| |
| |
| class OrderType(str, Enum): |
| CREATED = "OrderCreated" |
| CONFIRMED = "OrderConfirmed" |
| REJECTED = "OrderRejected" |
| |
| |
| @dataclass(frozen=True, slots=True) |
| class ArgNamespace: |
| connection_string: str |
| |
| |
| @dataclass(frozen=True, slots=True) |
| class Order: |
| order_type: OrderType |
| payload: str |
| |
| |
| def parse_args() -> ArgNamespace: |
| parser = argparse.ArgumentParser() |
| parser.add_argument( |
| "connection_string", |
| help=( |
| "Connection string for Iggy client, e.g. " |
| "'iggy+tcp://iggy:iggy@127.0.0.1:8090'" |
| ), |
| default="iggy+tcp://iggy:iggy@127.0.0.1:8090", |
| nargs="?", |
| type=str, |
| ) |
| return ArgNamespace(**vars(parser.parse_args())) |
| |
| |
| def generate_orders() -> Iterator[Order]: |
| order_id = 0 |
| while True: |
| order_id += 1 |
| group_id = (order_id + 2) // 3 |
| match order_id % 3: |
| case 1: |
| payload = { |
| "orderId": f"order-{group_id}", |
| "customerId": f"customer-{secrets.randbelow(100)}", |
| "amount": secrets.randbelow(10000) + 1, |
| } |
| yield Order(OrderType.CREATED, json.dumps(payload)) |
| case 2: |
| payload = { |
| "orderId": f"order-{group_id}", |
| "timestamp": int(time.time() * 1000), |
| } |
| yield Order(OrderType.CONFIRMED, json.dumps(payload)) |
| case _: |
| payload = { |
| "orderId": f"order-{group_id}", |
| "reason": "Insufficient balance", |
| } |
| yield Order(OrderType.REJECTED, json.dumps(payload)) |
| |
| |
| async def connect(connection_string: str) -> IggyClient: |
| client = IggyClient.from_connection_string(connection_string) |
| logger.info("Connecting to Iggy") |
| await client.connect() |
| logger.info("Connected") |
| return client |
| |
| |
| async def init_system(client: IggyClient) -> None: |
| logger.info(f"Creating stream with name {STREAM_NAME}...") |
| stream: StreamDetails | None = await client.get_stream(STREAM_NAME) |
| if stream is None: |
| await client.create_stream(name=STREAM_NAME) |
| logger.info("Stream was created successfully.") |
| else: |
| logger.warning(f"Stream {stream.name} already exists with ID {stream.id}") |
| |
| logger.info(f"Creating topic {TOPIC_NAME} in stream {STREAM_NAME}") |
| topic: TopicDetails | None = await client.get_topic(STREAM_NAME, TOPIC_NAME) |
| if topic is None: |
| await client.create_topic( |
| stream=STREAM_NAME, |
| partitions_count=1, |
| name=TOPIC_NAME, |
| ) |
| logger.info("Topic was created successfully.") |
| else: |
| logger.warning(f"Topic {topic.name} already exists with ID {topic.id}") |
| |
| |
| async def produce_messages(client: IggyClient, build_headers: HeadersBuilder) -> None: |
| interval = 0.5 |
| logger.info( |
| f"Messages will be sent to stream: {STREAM_NAME}, " |
| f"topic: {TOPIC_NAME}, partition: {PARTITION_ID} " |
| f"with interval {interval * 1000} ms." |
| ) |
| orders = generate_orders() |
| sent_batches = 0 |
| |
| while sent_batches < BATCHES_LIMIT: |
| messages: list[Message] = [] |
| for order in (next(orders) for _ in range(MESSAGES_PER_BATCH)): |
| headers = build_headers(order) |
| messages.append(Message(order.payload, user_headers=headers)) |
| logger.info( |
| f"Prepared {order.order_type} with headers: {format_headers(headers)}" |
| ) |
| |
| try: |
| await client.send_messages( |
| stream=STREAM_NAME, |
| topic=TOPIC_NAME, |
| partitioning=PARTITION_ID, |
| messages=messages, |
| ) |
| sent_batches += 1 |
| logger.info(f"Sent {len(messages)} message(s).") |
| except Exception as error: |
| logger.error(f"Exception type: {type(error).__name__}, message: {error}") |
| logger.exception(error) |
| break |
| |
| await asyncio.sleep(interval) |
| |
| logger.info(f"Sent {sent_batches} batches of messages, exiting.") |
| |
| |
| async def consume_messages(client: IggyClient, handle_message: MessageHandler) -> None: |
| interval = 0.5 |
| logger.info( |
| f"Messages will be consumed from stream: {STREAM_NAME}, " |
| f"topic: {TOPIC_NAME}, partition: {PARTITION_ID} " |
| f"with interval {interval * 1000} ms." |
| ) |
| consumed_batches = 0 |
| |
| while consumed_batches < BATCHES_LIMIT: |
| try: |
| logger.debug("Polling for messages...") |
| polled_messages = await client.poll_messages( |
| stream=STREAM_NAME, |
| topic=TOPIC_NAME, |
| partition_id=PARTITION_ID, |
| polling_strategy=PollingStrategy.Next(), |
| count=MESSAGES_PER_BATCH, |
| auto_commit=True, |
| ) |
| if not polled_messages: |
| logger.info("No messages found in current poll") |
| await asyncio.sleep(interval) |
| continue |
| |
| for message in polled_messages: |
| handle_message(message) |
| |
| consumed_batches += 1 |
| logger.info(f"Consumed {len(polled_messages)} message(s).") |
| await asyncio.sleep(interval) |
| except Exception as error: |
| logger.exception(f"Exception occurred while consuming messages: {error}") |
| break |
| |
| logger.info(f"Consumed {consumed_batches} batches of messages, exiting.") |
| |
| |
| def log_order(message_type: OrderType | None, payload: object) -> None: |
| match message_type: |
| case OrderType.CREATED: |
| logger.info(f"Order Created: {payload}") |
| case OrderType.CONFIRMED: |
| logger.info(f"Order Confirmed: {payload}") |
| case OrderType.REJECTED: |
| logger.info(f"Order Rejected: {payload}") |
| case _: |
| logger.warning(f"Received unknown message type: {message_type}") |
| |
| |
| def format_headers(headers: dict) -> dict[str, str]: |
| formatted: dict[str, str] = {} |
| for key, value in headers.items(): |
| formatted_key = repr(key) if isinstance(key, HeaderKey) else str(key) |
| if isinstance(value, bytes): |
| formatted[formatted_key] = f"bytes({value.hex()})" |
| elif isinstance(value, HeaderValue): |
| formatted[formatted_key] = repr(value) |
| else: |
| formatted[formatted_key] = f"{value!r} ({type(value).__name__})" |
| return formatted |