Source code for src.base.kafka.topics

"""Kafka topic configuration, provisioning, and consumer-group naming."""

import os

from confluent_kafka import KafkaError, KafkaException
from confluent_kafka.admin import AdminClient, NewPartitions, NewTopic

from src.base.kafka import config as kafka_config
from src.base.log_config import get_logger
from src.base.retry import retry_forever

logger = get_logger()


def normalize_topics(topics: str | list[str]) -> list[str]:
    if isinstance(topics, str):
        return [topics]
    return topics


def _as_bool(value) -> bool:
    if isinstance(value, bool):
        return value
    if isinstance(value, str):
        return value.lower() in {"1", "true", "yes", "on"}
    return bool(value)


def _topic_config(topic: str | None) -> dict:
    if topic is None:
        return {}

    exact_config = kafka_config.KAFKA_TOPIC_EXACT_CONFIG.get(topic)
    if exact_config is not None:
        return exact_config

    matched_stage = None
    matched_prefix_length = -1
    for stage_name, topic_prefix in kafka_config.KAFKA_PIPELINE_TOPIC_PREFIXES.items():
        if not topic_prefix:
            continue
        if topic == topic_prefix or topic.startswith(f"{topic_prefix}-"):
            if len(topic_prefix) > matched_prefix_length:
                matched_stage = stage_name
                matched_prefix_length = len(topic_prefix)

    if matched_stage is None:
        return {}
    return kafka_config.KAFKA_TOPIC_STAGE_CONFIG.get(matched_stage, {})


def _runtime_min_topic_partitions() -> int:
    try:
        return int(os.getenv("KAFKA_TOPIC_MIN_PARTITIONS", "1"))
    except ValueError:
        return 1


def _desired_topic_partitions(
    topic: str | None = None, override: int | None = None
) -> int:
    topic_config = _topic_config(topic)
    configured_partitions = override
    if configured_partitions is None:
        configured_partitions = topic_config.get(
            "partitions", kafka_config.KAFKA_TOPIC_DEFAULT_PARTITIONS
        )
    return max(
        1,
        kafka_config.NUMBER_OF_INSTANCES,
        _runtime_min_topic_partitions(),
        int(configured_partitions),
    )


def _topic_replication_factor(
    topic: str | None = None, override: int | None = None
) -> int:
    broker_count = max(1, len(kafka_config.KAFKA_BROKERS))
    topic_config = _topic_config(topic)
    configured_replication_factor = override
    if configured_replication_factor is None:
        configured_replication_factor = topic_config.get(
            "replication_factor", kafka_config.KAFKA_TOPIC_REPLICATION_FACTOR
        )
    configured_replication_factor = max(1, int(configured_replication_factor))
    return min(configured_replication_factor, broker_count)


def topic_partition_count(cluster_metadata, topic: str) -> int | None:
    topics_metadata = getattr(cluster_metadata, "topics", {})
    if isinstance(topics_metadata, dict):
        topic_metadata = topics_metadata.get(topic)
        if topic_metadata is None:
            return None
        partitions = getattr(topic_metadata, "partitions", None)
        return 1 if partitions is None else len(partitions)
    return 1 if topic in topics_metadata else None


[docs] class KafkaTopicManager: """Reconcile a set of topics with the configured partition policy.""" def __init__(self, admin_client: AdminClient) -> None: self.admin_client = admin_client
[docs] def ensure( self, topics: str | list[str], target_partitions: int | None = None, replication_factor: int | None = None, auto_expand_partitions: bool | None = None, ) -> dict[str, int]: normalized_topics = normalize_topics(topics) target_by_topic = { topic: _desired_topic_partitions(topic, target_partitions) for topic in normalized_topics } replication_by_topic = { topic: _topic_replication_factor(topic, replication_factor) for topic in normalized_topics } should_expand = ( _as_bool(kafka_config.KAFKA_TOPIC_AUTO_EXPAND_PARTITIONS) if auto_expand_partitions is None else _as_bool(auto_expand_partitions) ) cluster_metadata = retry_forever( lambda: self.admin_client.list_topics(timeout=10), "Kafka metadata lookup", kafka_config.RETRY_SETTINGS, ) topics_metadata = getattr(cluster_metadata, "topics", {}) existing_topics = ( set(topics_metadata.keys()) if isinstance(topics_metadata, dict) else set(topics_metadata) ) missing_topics = [ topic for topic in normalized_topics if topic not in existing_topics ] if missing_topics: logger.info("Creating Kafka topics %s.", missing_topics) retry_forever( lambda: self._wait_for_admin_futures( self.admin_client.create_topics( [ NewTopic( topic, target_by_topic[topic], replication_by_topic[topic], ) for topic in missing_topics ] ), "create topic", ), f"Kafka topic creation for {missing_topics}", kafka_config.RETRY_SETTINGS, ) if not should_expand: return target_by_topic cluster_metadata = retry_forever( lambda: self.admin_client.list_topics(timeout=10), "Kafka metadata lookup after topic creation", kafka_config.RETRY_SETTINGS, ) topics_to_expand = [] for topic in normalized_topics: current_partition_count = topic_partition_count(cluster_metadata, topic) if current_partition_count is None: continue target = target_by_topic[topic] if current_partition_count < target: logger.info( "Expanding Kafka topic '%s' from %d to %d partition(s).", topic, current_partition_count, target, ) topics_to_expand.append(NewPartitions(topic, target)) if topics_to_expand: retry_forever( lambda: self._wait_for_admin_futures( self.admin_client.create_partitions(topics_to_expand), "expand partitions", ), "Kafka partition expansion for " f"{[str(topic) for topic in topics_to_expand]}", kafka_config.RETRY_SETTINGS, ) return target_by_topic
@staticmethod def _wait_for_admin_futures(futures: dict, operation: str) -> None: for topic, future in futures.items(): try: future.result() except KafkaException as exception: if operation == "create topic" and _is_topic_already_created(exception): logger.info("Kafka topic '%s' already exists.", topic) continue if ( operation == "expand partitions" and _is_partition_count_already_satisfied(exception) ): logger.info( "Kafka topic '%s' already has enough partitions.", topic ) continue raise
def _is_topic_already_created(exception: Exception) -> bool: kafka_error = exception.args[0] if getattr(exception, "args", None) else None topic_already_exists_code = getattr(KafkaError, "TOPIC_ALREADY_EXISTS", None) if ( topic_already_exists_code is not None and hasattr(kafka_error, "code") and kafka_error.code() == topic_already_exists_code ): return True return "already exists" in str(exception).lower() def _is_partition_count_already_satisfied(exception: Exception) -> bool: message = str(exception).lower() return "already has" in message or "smaller than current" in message
[docs] def ensure_topics( admin_client: AdminClient, topics: str | list[str], target_partitions: int | None = None, replication_factor: int | None = None, auto_expand_partitions: bool | None = None, ) -> dict[str, int]: """Preserve the existing functional API around the topic manager.""" return KafkaTopicManager(admin_client).ensure( topics, target_partitions, replication_factor, auto_expand_partitions, )
def _sanitize_consumer_group_part(value: str) -> str: return "".join( character if character.isalnum() or character in "._-" else "_" for character in value )
[docs] def build_consumer_group_id(topics: str | list[str]) -> str: normalized_topics = sorted(normalize_topics(topics)) topic_suffix = "__".join( _sanitize_consumer_group_part(topic) for topic in normalized_topics ) if not topic_suffix: return kafka_config.CONSUMER_GROUP_ID return f"{kafka_config.CONSUMER_GROUP_ID}.{topic_suffix}"