-
Notifications
You must be signed in to change notification settings - Fork 28
feat: add Kafka publish for build/batch state-change events #1381
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -268,6 +268,21 @@ The custom configuration options for AMQP 1.0 messaging are listed below: | |
| * `IIB_MESSAGING_URLS` - a list of AMQP(S) URLs to use when connecting to the AMQP 1.0 broker. This | ||
| must be set if messaging is enabled. | ||
|
|
||
| The custom configuration options for Kafka messaging are listed below. Kafka messaging is optional. | ||
| When configured, IIB publishes the state-change events to configured Kafka topics : | ||
|
|
||
| * `IIB_KAFKA_BROKERS` - a list of Kafka broker addresses (e.g. `['broker1:9096', 'broker2:9096']`). | ||
| If this is not set, Kafka messaging is disabled entirely. | ||
| * `IIB_KAFKA_USERNAME` - the SASL username for Kafka authentication. Both `IIB_KAFKA_USERNAME` and | ||
| `IIB_KAFKA_PASSWORD` must be set together. | ||
| * `IIB_KAFKA_PASSWORD` - the SASL password for Kafka authentication. | ||
| * `IIB_KAFKA_SSL_CAFILE` - the path to a CA certificate file used to verify the Kafka broker's TLS | ||
| certificate. This defaults to `/etc/pki/tls/certs/ca-bundle.crt`. | ||
| * `IIB_KAFKA_BUILD_STATE_TOPIC` - the Kafka topic to publish build request state-change messages to. | ||
| If this is not set, build state-change messages will not be published to Kafka. | ||
| * `IIB_KAFKA_BATCH_STATE_TOPIC` - the Kafka topic to publish batch state-change messages to. | ||
| If this is not set, batch state-change messages will not be published to Kafka. | ||
|
|
||
| If you wish to configure AWS S3 bucket for storing artifact files, the following **environment variables** | ||
| must be set along with `IIB_AWS_S3_BUCKET_NAME` config variable: | ||
|
|
||
|
|
@@ -508,10 +523,10 @@ modifications, such as registry replacement, will still be applied. | |
|
|
||
| ## Messaging | ||
|
|
||
| IIB has support to send messages to an AMQP 1.0 broker. If configured to do so, IIB will send | ||
| messages when a build request state changes and when a batch state changes. Please note that if a | ||
| message can't be sent due to an infrastructure issue, the build request will continue as it is not | ||
| considered a fatal error. | ||
| IIB has support to send messages to an AMQP 1.0 broker (will be depricated in future) and to Apache Kafka topics. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] documentation-quality Contains 'depricated' (typo for 'deprecated') and 'Please notethat' (missing space between 'note' and 'that'). Suggested fix: Correct to 'deprecated' and 'Please note that'.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks, will correct it. |
||
| If configured to do so, IIB will send messages when a build request state changes and when a batch | ||
| state changes. Please notethat if a message can't be sent due to an infrastructure issue, the build request will | ||
| continue as it is not considered a fatal error. | ||
|
|
||
| The build request state change message body is the JSON representation of the build request in | ||
| the non-verbose format like in the `/builds` API endpoint. The message has the following keys set in | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,13 @@ class Config(object): | |
| IIB_REQUEST_RELATED_BUNDLES_DIR: Optional[str] = None | ||
| IIB_REQUEST_RECURSIVE_RELATED_BUNDLES_DIR: Optional[str] = None | ||
| IIB_USER_TO_QUEUE: Union[Dict[str, str], Dict[str, Dict[str, str]]] = _get_empty_dict_str_str() | ||
| # Kafka messaging configuration | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] naming-convention Existing messaging config uses Suggested fix: Consider
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As most of the configs are for Kafka, it suits better to have kafka for these configs. |
||
| IIB_KAFKA_BATCH_STATE_TOPIC: Optional[str] = None | ||
| IIB_KAFKA_BROKERS: Optional[List[str]] = None | ||
| IIB_KAFKA_BUILD_STATE_TOPIC: Optional[str] = None | ||
| IIB_KAFKA_SSL_CAFILE: str = '/etc/pki/tls/certs/ca-bundle.crt' | ||
| IIB_KAFKA_USERNAME: Optional[str] = None | ||
| IIB_KAFKA_PASSWORD: Optional[str] = None | ||
| IIB_WORKER_USERNAMES: List[str] = [] | ||
| SQLALCHEMY_TRACK_MODIFICATIONS: bool = False | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| # SPDX-License-Identifier: GPL-3.0-or-later | ||
| import json | ||
| import threading | ||
| from typing import Any, Dict, Optional, Union | ||
|
|
||
| from flask import current_app | ||
| from kafka import KafkaProducer | ||
|
|
||
| from iib.web.iib_static_types import ( | ||
| BaseClassRequestResponse, | ||
| BatchRequestResponseList, | ||
| ) | ||
|
|
||
| _kafka_producer: Optional[KafkaProducer] = None | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] architectural-coherence The Kafka path introduces a process-wide module-level singleton while the existing AMQP path creates a fresh connection per call. Tests work around this by directly resetting Suggested fix: Document the rationale for the singleton lifetime in a module-level docstring, or scope the producer to the Flask application context.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Creating Kafka connection for each message will be a heavy weight operation and considering the scale of request IIB receives, having a single Producer reduces the load at a good factor. So I will stick with this one. |
||
| _producer_lock = threading.Lock() | ||
|
|
||
|
|
||
| def get_kafka_producer() -> Optional[KafkaProducer]: | ||
| """Return a cached, thread-safe KafkaProducer.""" | ||
| global _kafka_producer | ||
|
|
||
| # First check without lock for performance | ||
| if _kafka_producer is not None: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] edge-case Double-checked locking caches the producer indefinitely. No reconnection, cache-invalidation, or credential-rotation mechanism exists. The application must be fully restarted for new credentials or to recover from certain failure modes. Suggested fix: Add a mechanism to invalidate the cached producer (e.g., TTL-based refresh, a reset function, or a signal handler).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It will become complex to add such facility in a single MR. It might be better to create a separate MR in case if the current implementation causes issue. |
||
| return _kafka_producer | ||
|
|
||
| with _producer_lock: | ||
| # Second check inside lock to prevent race conditions | ||
| if _kafka_producer is not None: | ||
| return _kafka_producer | ||
|
|
||
| conf = current_app.config | ||
| brokers = conf.get('IIB_KAFKA_BROKERS') | ||
| if not brokers: | ||
| return None | ||
|
|
||
| try: | ||
| username = conf.get('IIB_KAFKA_USERNAME') | ||
| password = conf.get('IIB_KAFKA_PASSWORD') | ||
|
|
||
| if not username or not password: | ||
| current_app.logger.error('Kafka credentials missing, cannot initialize producer') | ||
| return None | ||
|
|
||
| producer_kwargs = { | ||
| 'bootstrap_servers': brokers, | ||
| 'security_protocol': 'SASL_SSL', | ||
| 'sasl_mechanism': 'SCRAM-SHA-512', | ||
| 'sasl_plain_username': username, | ||
| 'sasl_plain_password': password, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] secrets-handling Kafka password placed in Suggested fix: Clear password from
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think we are having any such custom logging configuration. |
||
| 'value_serializer': lambda v: json.dumps(v).encode('utf-8'), | ||
| 'key_serializer': lambda k: str(k).encode('utf-8') if k is not None else None, | ||
| 'linger_ms': 5, | ||
| } | ||
|
|
||
| ca_file = conf.get('IIB_KAFKA_SSL_CAFILE') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] TLS-configuration When
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If no IIB_KAFKA_SSL_CAFILE config is found, it should use the system default. |
||
| if ca_file: | ||
| producer_kwargs['ssl_cafile'] = ca_file | ||
|
|
||
| _kafka_producer = KafkaProducer(**producer_kwargs) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [medium] resource-lifecycle The module-level Suggested fix: Register a Flask
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's the tradeoff we have to accept for sending message async and not blocking the main thread for sending messages. |
||
|
|
||
| current_app.logger.info('Kafka producer initialised') | ||
| return _kafka_producer | ||
| except Exception: | ||
| current_app.logger.exception('Failed to initialise KafkaProducer') | ||
| return None | ||
|
|
||
|
|
||
| def on_send_error(excp): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] api-shape-patterns
Suggested fix: Annotate: |
||
| """Log failed Kafka message delivery in a background-thread-safe way.""" | ||
| import logging | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] code-organization
Suggested fix: Move
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The flush operation is performed in background env where we need logging module , as logging is only reqiuired by on_send_error, It's a good implementation to import module just before use. |
||
|
|
||
| logging.getLogger(__name__).error('Failed to deliver message to Kafka', exc_info=excp) | ||
|
|
||
|
|
||
| def send_kafka_message( | ||
| producer: KafkaProducer, | ||
| topic: str, | ||
| content: Union[BaseClassRequestResponse, BatchRequestResponseList], | ||
| properties: Optional[Dict[str, Any]] = None, | ||
| ) -> None: | ||
| """Send a single message to a Kafka topic asynchronously.""" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] documentation-comment-format
Suggested fix: Expand docstrings to include |
||
| properties = properties or {} | ||
| headers = [(k, str(v).encode('utf-8')) for k, v in properties.items() if v is not None] | ||
| message_key = properties.get('id', properties.get('batch')) | ||
|
|
||
| try: | ||
| current_app.logger.info(f'Queuing message for Kafka topic {topic}') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] error-handling-idiom Logger calls use f-strings; the established convention in Suggested fix: Replace f-strings with
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will implement. |
||
|
|
||
| future = producer.send(topic, key=message_key, value=content, headers=headers) | ||
|
|
||
| # Attach callbacks for async failure handling | ||
| future.add_errback(on_send_error) | ||
|
|
||
| except Exception: | ||
| current_app.logger.exception(f'Failed to queue message for Kafka topic {topic}') | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,13 +16,82 @@ | |
| BatchRequestResponseList, | ||
| ) | ||
| from iib.web.models import Batch, Request, RequestStateMapping | ||
| from iib.web.kafka_producer import get_kafka_producer, send_kafka_message | ||
|
|
||
| __all__ = ['Envelope', 'json_to_envelope', 'send_messages', 'send_message_for_state_change'] | ||
|
|
||
|
|
||
| Envelope = namedtuple('Envelope', 'address message') | ||
|
|
||
|
|
||
| def _build_request_state_change_data( | ||
| request: Request, | ||
| ) -> tuple: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] api-shape-patterns
Suggested fix: Use |
||
| """ | ||
| Build the content and properties for a request state change message. | ||
|
|
||
| Used by both the legacy AMQP and Kafka paths. | ||
|
|
||
| :param iib.web.models.Request request: the request that changed states | ||
| :return: a tuple of (content, properties) | ||
| :rtype: tuple | ||
| """ | ||
| request_json = cast(BaseClassRequestResponse, request.to_json(verbose=False)) | ||
| properties = { | ||
| 'batch': request_json['batch'], | ||
| 'id': request_json['id'], | ||
| 'state': request_json['state'], | ||
| 'user': request_json['user'], | ||
| } | ||
| return request_json, properties | ||
|
|
||
|
|
||
| def _build_batch_state_change_data( | ||
| batch: Batch, | ||
| new_batch: Optional[bool] = False, | ||
| ) -> Optional[tuple]: | ||
| """ | ||
| Build the content and properties for a batch state change message. | ||
|
|
||
| Returns ``None`` when no message should be sent. | ||
| Used by both the legacy AMQP and Kafka paths. | ||
|
|
||
| :param iib.web.models.Batch batch: the batch that changed states | ||
| :param bool new_batch: if ``True``, a new batch message will be generated | ||
| :return: a tuple of (content, properties) or None | ||
| :rtype: tuple or None | ||
| """ | ||
| if new_batch: | ||
| batch_state = 'in_progress' | ||
| else: | ||
| batch_state = batch.state | ||
|
|
||
| if not (new_batch or batch_state in RequestStateMapping.get_final_states()): | ||
| return None | ||
|
|
||
| batch_username = getattr(batch.user, 'username', None) | ||
| content: BatchRequestResponseList = { | ||
| 'batch': batch.id, | ||
| 'annotations': batch.annotations, | ||
| 'requests': [ | ||
| { | ||
| 'id': r.id, | ||
| 'organization': getattr(r, 'organization', None), | ||
| 'request_type': r.type_name, | ||
| } | ||
| for r in batch.requests | ||
| ], | ||
| 'state': batch_state, | ||
| 'user': batch_username, | ||
| } | ||
| properties = { | ||
| 'batch': batch.id, | ||
| 'state': batch_state, | ||
| 'user': batch_username, | ||
| } | ||
| return content, properties | ||
|
|
||
|
|
||
| def _get_batch_state_change_envelope( | ||
| batch: Batch, | ||
| new_batch: Optional[bool] = False, | ||
|
|
@@ -47,34 +116,10 @@ def _get_batch_state_change_envelope( | |
| ) | ||
| return None | ||
|
|
||
| if new_batch: | ||
| # Avoid querying the database for the batch state since we know it's a new batch | ||
| batch_state = 'in_progress' | ||
| else: | ||
| batch_state = batch.state | ||
|
|
||
| if new_batch or batch_state in RequestStateMapping.get_final_states(): | ||
| data = _build_batch_state_change_data(batch, new_batch) | ||
| if data: | ||
| current_app.logger.debug('Preparing to send a state change message for batch %d', batch.id) | ||
| batch_username = getattr(batch.user, 'username', None) | ||
| content: BatchRequestResponseList = { | ||
| 'batch': batch.id, | ||
| 'annotations': batch.annotations, | ||
| 'requests': [ | ||
| { | ||
| 'id': request.id, | ||
| 'organization': getattr(request, 'organization', None), | ||
| 'request_type': request.type_name, | ||
| } | ||
| for request in batch.requests | ||
| ], | ||
| 'state': batch_state, | ||
| 'user': batch_username, | ||
| } | ||
| properties = { | ||
| 'batch': batch.id, | ||
| 'state': batch_state, | ||
| 'user': batch_username, | ||
| } | ||
| content, properties = data | ||
| return json_to_envelope(batch_address, content, properties) | ||
| return None | ||
|
|
||
|
|
@@ -98,15 +143,8 @@ def _get_request_state_change_envelope(request: Request) -> Optional[Envelope]: | |
| return None | ||
|
|
||
| current_app.logger.debug('Preparing to send a state change message for request %d', request.id) | ||
| # cast from Union - see Request.to_json | ||
| request_json = cast(BaseClassRequestResponse, request.to_json(verbose=False)) | ||
| properties = { | ||
| 'batch': request_json['batch'], | ||
| 'id': request_json['id'], | ||
| 'state': request_json['state'], | ||
| 'user': request_json['user'], | ||
| } | ||
| return json_to_envelope(request_address, request_json, properties) | ||
| content, properties = _build_request_state_change_data(request) | ||
| return json_to_envelope(request_address, content, properties) | ||
|
|
||
|
|
||
| def _get_ssl_domain() -> Optional[proton.SSLDomain]: | ||
|
|
@@ -199,6 +237,41 @@ def send_messages(envelopes: List[Envelope]) -> None: | |
| connection.close() | ||
|
|
||
|
|
||
| def _send_kafka_messages( | ||
| requests: List[Request], | ||
| batch: Batch, | ||
| new_batch: Optional[bool] = False, | ||
| ) -> None: | ||
| """ | ||
| Send request and batch state-change messages to Kafka. | ||
|
|
||
| :param list requests: one or more requests whose state changed | ||
| :param iib.web.models.Batch batch: the batch associated with the requests | ||
| :param bool new_batch: if ``True``, a batch-creation message is sent | ||
| """ | ||
| try: | ||
| producer = get_kafka_producer() | ||
| if not producer: | ||
| return | ||
|
|
||
| conf = current_app.config | ||
|
|
||
| build_topic = conf.get('IIB_KAFKA_BUILD_STATE_TOPIC') | ||
| if build_topic: | ||
| for request in requests: | ||
| content, properties = _build_request_state_change_data(request) | ||
| send_kafka_message(producer, build_topic, content, properties) | ||
|
|
||
| batch_topic = conf.get('IIB_KAFKA_BATCH_STATE_TOPIC') | ||
| if batch_topic: | ||
| batch_data = _build_batch_state_change_data(batch, new_batch) | ||
| if batch_data: | ||
| content, properties = batch_data | ||
| send_kafka_message(producer, batch_topic, content, properties) | ||
| except: # noqa: E722 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [medium] error-handling Bare Suggested fix: Replace |
||
| current_app.logger.exception('Failed to send one or more messages') | ||
|
|
||
|
|
||
| def send_message_for_state_change(request: Request, new_batch_msg: Optional[bool] = False) -> None: | ||
| """ | ||
| Send the appropriate message(s) based on a build request state change. | ||
|
|
@@ -223,6 +296,8 @@ def send_message_for_state_change(request: Request, new_batch_msg: Optional[bool | |
| if envelopes: | ||
| send_messages(envelopes) | ||
|
|
||
| _send_kafka_messages([request], request.batch, new_batch=new_batch_msg) | ||
|
|
||
|
|
||
| def send_messages_for_new_batch_of_requests(requests: List[Request]) -> None: | ||
| """ | ||
|
|
@@ -250,3 +325,5 @@ def send_messages_for_new_batch_of_requests(requests: List[Request]) -> None: | |
|
|
||
| if envelopes: | ||
| send_messages(envelopes) | ||
|
|
||
| _send_kafka_messages(requests, batch, new_batch=True) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[low] scope-creep
States AMQP 'will be deprecated' but the PR provides no deprecation warning, migration guide, or timeline.
Suggested fix: Remove the deprecation claim from the README or open a follow-up issue tracking AMQP deprecation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No warning reqired, as the web service logs is only available to operator foundry team.