blob: 967afe930bd914fb434b62a5a903371c3ef4599d [file]
# 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