sify-queue-kafka 0.1.3__tar.gz → 0.1.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sify_queue_kafka-0.1.3 → sify_queue_kafka-0.1.4}/PKG-INFO +14 -13
- {sify_queue_kafka-0.1.3 → sify_queue_kafka-0.1.4}/README.md +13 -12
- {sify_queue_kafka-0.1.3 → sify_queue_kafka-0.1.4}/pyproject.toml +1 -1
- sify_queue_kafka-0.1.4/queue_kafka/__init__.py +40 -0
- sify_queue_kafka-0.1.4/queue_kafka/config.py +15 -0
- sify_queue_kafka-0.1.4/queue_kafka/consumer.py +101 -0
- sify_queue_kafka-0.1.4/queue_kafka/decorators.py +10 -0
- sify_queue_kafka-0.1.4/queue_kafka/dispatcher.py +69 -0
- sify_queue_kafka-0.1.4/queue_kafka/exceptions.py +33 -0
- sify_queue_kafka-0.1.4/queue_kafka/idempotency.py +17 -0
- sify_queue_kafka-0.1.4/queue_kafka/models.py +23 -0
- sify_queue_kafka-0.1.4/queue_kafka/producer.py +74 -0
- sify_queue_kafka-0.1.4/queue_kafka/registry.py +13 -0
- sify_queue_kafka-0.1.4/queue_kafka/retry.py +45 -0
- sify_queue_kafka-0.1.4/queue_kafka/topic_resolver.py +6 -0
- sify_queue_kafka-0.1.4/queue_kafka/utils.py +22 -0
- {sify_queue_kafka-0.1.3 → sify_queue_kafka-0.1.4}/setup.cfg +1 -1
- {sify_queue_kafka-0.1.3 → sify_queue_kafka-0.1.4}/sify_queue_kafka.egg-info/PKG-INFO +14 -13
- sify_queue_kafka-0.1.4/sify_queue_kafka.egg-info/SOURCES.txt +21 -0
- sify_queue_kafka-0.1.4/sify_queue_kafka.egg-info/top_level.txt +1 -0
- sify_queue_kafka-0.1.3/sify_queue_kafka.egg-info/SOURCES.txt +0 -15
- sify_queue_kafka-0.1.3/sify_queue_kafka.egg-info/top_level.txt +0 -1
- sify_queue_kafka-0.1.3/tests/test_email_signup.py +0 -101
- sify_queue_kafka-0.1.3/tests/test_image_pipeline.py +0 -226
- sify_queue_kafka-0.1.3/tests/test_notify_service.py +0 -172
- sify_queue_kafka-0.1.3/tests/test_order_pipeline.py +0 -99
- sify_queue_kafka-0.1.3/tests/test_order_service.py +0 -160
- sify_queue_kafka-0.1.3/tests/test_payment_service.py +0 -193
- sify_queue_kafka-0.1.3/tests/test_vehicle_service.py +0 -164
- {sify_queue_kafka-0.1.3 → sify_queue_kafka-0.1.4}/sify_queue_kafka.egg-info/dependency_links.txt +0 -0
- {sify_queue_kafka-0.1.3 → sify_queue_kafka-0.1.4}/sify_queue_kafka.egg-info/requires.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sify-queue-kafka
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Enterprise Queue management Framework
|
|
5
5
|
Author: sifymodernization
|
|
6
6
|
Author-email: sifymodernization <sifymodernization.dev@sifycorp.com>
|
|
@@ -192,32 +192,33 @@ if __name__ == "__main__":
|
|
|
192
192
|
|
|
193
193
|
| Option | Type | Default | Description |
|
|
194
194
|
|--------|------|---------|-------------|
|
|
195
|
-
| `bootstrap_servers` | str |
|
|
196
|
-
| `group_id` | str |
|
|
195
|
+
| `bootstrap_servers` | str | "localhost:9092" | Kafka bootstrap servers |
|
|
196
|
+
| `group_id` | str | "" | Consumer group ID. Is generated with the tenant_id and source from the consumer config |
|
|
197
197
|
| `enable_auto_commit` | bool | False | Enable auto commit of offsets |
|
|
198
198
|
| `auto_offset_reset` | str | "earliest" | Offset reset policy |
|
|
199
199
|
| `enable_idempotency` | bool | True | Enable duplicate detection |
|
|
200
200
|
| `enable_retry` | bool | True | Enable retry with backoff |
|
|
201
201
|
| `dead_letter_topic` | str | None | Topic for failed events |
|
|
202
|
-
| `stage` | str |
|
|
202
|
+
| `stage` | str | "" | Processing stage name |
|
|
203
|
+
| `enable_idempotent_producer` | bool | False | Enable idempotent producer |
|
|
203
204
|
|
|
204
205
|
## Event Model
|
|
205
206
|
|
|
206
207
|
```python
|
|
207
208
|
class KafkaEvent(BaseModel):
|
|
208
|
-
eventId: str
|
|
209
|
+
eventId: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
209
210
|
eventType: str
|
|
210
211
|
eventVersion: str
|
|
211
212
|
source: str
|
|
212
213
|
tenantId: str
|
|
213
|
-
timestamp: datetime
|
|
214
|
-
correlationId: Optional[str]
|
|
215
|
-
traceId: Optional[str]
|
|
216
|
-
priority: Optional[str]
|
|
217
|
-
retryCount: int
|
|
218
|
-
maxRetries: int
|
|
214
|
+
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
|
215
|
+
correlationId: Optional[str] = None
|
|
216
|
+
traceId: Optional[str] = None
|
|
217
|
+
priority: Optional[str] = "MEDIUM"
|
|
218
|
+
retryCount: int = 0
|
|
219
|
+
maxRetries: int = 5
|
|
219
220
|
payload: Dict[str, Any]
|
|
220
|
-
metadata: Optional[Dict[str, Any]]
|
|
221
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
221
222
|
```
|
|
222
223
|
|
|
223
224
|
## Exception Types
|
|
@@ -260,7 +261,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|
|
260
261
|
|
|
261
262
|
## Changelog
|
|
262
263
|
|
|
263
|
-
### v0.1.
|
|
264
|
+
### v0.1.4
|
|
264
265
|
- Initial release
|
|
265
266
|
- Basic producer/consumer functionality
|
|
266
267
|
- Event validation with Pydantic
|
|
@@ -159,32 +159,33 @@ if __name__ == "__main__":
|
|
|
159
159
|
|
|
160
160
|
| Option | Type | Default | Description |
|
|
161
161
|
|--------|------|---------|-------------|
|
|
162
|
-
| `bootstrap_servers` | str |
|
|
163
|
-
| `group_id` | str |
|
|
162
|
+
| `bootstrap_servers` | str | "localhost:9092" | Kafka bootstrap servers |
|
|
163
|
+
| `group_id` | str | "" | Consumer group ID. Is generated with the tenant_id and source from the consumer config |
|
|
164
164
|
| `enable_auto_commit` | bool | False | Enable auto commit of offsets |
|
|
165
165
|
| `auto_offset_reset` | str | "earliest" | Offset reset policy |
|
|
166
166
|
| `enable_idempotency` | bool | True | Enable duplicate detection |
|
|
167
167
|
| `enable_retry` | bool | True | Enable retry with backoff |
|
|
168
168
|
| `dead_letter_topic` | str | None | Topic for failed events |
|
|
169
|
-
| `stage` | str |
|
|
169
|
+
| `stage` | str | "" | Processing stage name |
|
|
170
|
+
| `enable_idempotent_producer` | bool | False | Enable idempotent producer |
|
|
170
171
|
|
|
171
172
|
## Event Model
|
|
172
173
|
|
|
173
174
|
```python
|
|
174
175
|
class KafkaEvent(BaseModel):
|
|
175
|
-
eventId: str
|
|
176
|
+
eventId: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
176
177
|
eventType: str
|
|
177
178
|
eventVersion: str
|
|
178
179
|
source: str
|
|
179
180
|
tenantId: str
|
|
180
|
-
timestamp: datetime
|
|
181
|
-
correlationId: Optional[str]
|
|
182
|
-
traceId: Optional[str]
|
|
183
|
-
priority: Optional[str]
|
|
184
|
-
retryCount: int
|
|
185
|
-
maxRetries: int
|
|
181
|
+
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
|
182
|
+
correlationId: Optional[str] = None
|
|
183
|
+
traceId: Optional[str] = None
|
|
184
|
+
priority: Optional[str] = "MEDIUM"
|
|
185
|
+
retryCount: int = 0
|
|
186
|
+
maxRetries: int = 5
|
|
186
187
|
payload: Dict[str, Any]
|
|
187
|
-
metadata: Optional[Dict[str, Any]]
|
|
188
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
188
189
|
```
|
|
189
190
|
|
|
190
191
|
## Exception Types
|
|
@@ -227,7 +228,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|
|
227
228
|
|
|
228
229
|
## Changelog
|
|
229
230
|
|
|
230
|
-
### v0.1.
|
|
231
|
+
### v0.1.4
|
|
231
232
|
- Initial release
|
|
232
233
|
- Basic producer/consumer functionality
|
|
233
234
|
- Event validation with Pydantic
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sify Queue Kafka
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.4"
|
|
6
|
+
|
|
7
|
+
from .config import QueueConfig
|
|
8
|
+
from .models import KafkaEvent
|
|
9
|
+
from .producer import Producer
|
|
10
|
+
from .consumer import Consumer
|
|
11
|
+
from .dispatcher import Dispatcher
|
|
12
|
+
from .decorators import event_handler
|
|
13
|
+
from .registry import EventRegistry, registry
|
|
14
|
+
from .exceptions import (
|
|
15
|
+
QueueSDKError,
|
|
16
|
+
ConfigurationError,
|
|
17
|
+
HandlerNotFoundError,
|
|
18
|
+
EventProcessingError,
|
|
19
|
+
ProducerError,
|
|
20
|
+
ConsumerError,
|
|
21
|
+
SerializationError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"QueueConfig",
|
|
26
|
+
"KafkaEvent",
|
|
27
|
+
"Producer",
|
|
28
|
+
"Consumer",
|
|
29
|
+
"Dispatcher",
|
|
30
|
+
"event_handler",
|
|
31
|
+
"registry",
|
|
32
|
+
"EventRegistry",
|
|
33
|
+
"QueueSDKError",
|
|
34
|
+
"ConfigurationError",
|
|
35
|
+
"HandlerNotFoundError",
|
|
36
|
+
"EventProcessingError",
|
|
37
|
+
"ProducerError",
|
|
38
|
+
"ConsumerError",
|
|
39
|
+
"SerializationError",
|
|
40
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
@dataclass
|
|
4
|
+
class QueueConfig:
|
|
5
|
+
bootstrap_servers: str = "localhost:9092"
|
|
6
|
+
group_id: str = ""
|
|
7
|
+
enable_auto_commit: bool = False
|
|
8
|
+
auto_offset_reset: str = "earliest"
|
|
9
|
+
|
|
10
|
+
enable_idempotency: bool = True
|
|
11
|
+
enable_retry: bool = True
|
|
12
|
+
dead_letter_topic: str = None
|
|
13
|
+
stage: str = ""
|
|
14
|
+
|
|
15
|
+
enable_idempotent_producer: bool = False
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from kafka import KafkaConsumer
|
|
3
|
+
from typing import Optional, Dict, Union, List
|
|
4
|
+
|
|
5
|
+
from .models import KafkaEvent
|
|
6
|
+
from .utils import get_logger
|
|
7
|
+
from .dispatcher import Dispatcher
|
|
8
|
+
from .config import QueueConfig
|
|
9
|
+
from .decorators import registry
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
BOOTSTRAP = "localhost:9092"
|
|
13
|
+
|
|
14
|
+
class Consumer:
|
|
15
|
+
def __init__( self, topics: Union[str, List[str]], config: Optional[Dict] = None, ):
|
|
16
|
+
# Convert topics to list
|
|
17
|
+
if isinstance(topics, str):
|
|
18
|
+
topics = [topics]
|
|
19
|
+
|
|
20
|
+
# Extract client overrides (only allowed fields)
|
|
21
|
+
stage = None
|
|
22
|
+
dead_letter_topic = None
|
|
23
|
+
tenant_id = None
|
|
24
|
+
source = None
|
|
25
|
+
|
|
26
|
+
if config:
|
|
27
|
+
stage = config.get("stage")
|
|
28
|
+
dead_letter_topic = config.get("dead_letter_topic")
|
|
29
|
+
tenant_id = config.get("tenant_id")
|
|
30
|
+
source = config.get("source")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# SDK-controlled configuration
|
|
34
|
+
self.config = QueueConfig(
|
|
35
|
+
bootstrap_servers=BOOTSTRAP,
|
|
36
|
+
group_id=f"{tenant_id}.{source}.{stage}.group",
|
|
37
|
+
enable_auto_commit=True,
|
|
38
|
+
auto_offset_reset="earliest",
|
|
39
|
+
enable_retry=True,
|
|
40
|
+
enable_idempotency=True,
|
|
41
|
+
dead_letter_topic=dead_letter_topic,
|
|
42
|
+
stage=stage,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
self.running = True
|
|
46
|
+
self.logger = get_logger(f"Consumer.{self.config.stage or 'default'}")
|
|
47
|
+
|
|
48
|
+
# Initialize Kafka Consumer
|
|
49
|
+
self.consumer = KafkaConsumer(
|
|
50
|
+
bootstrap_servers=self.config.bootstrap_servers,
|
|
51
|
+
group_id=self.config.group_id,
|
|
52
|
+
enable_auto_commit=self.config.enable_auto_commit,
|
|
53
|
+
auto_offset_reset=self.config.auto_offset_reset,
|
|
54
|
+
value_deserializer=lambda m: json.loads(m.decode("utf-8")),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Subscribe once
|
|
58
|
+
self.consumer.subscribe(topics)
|
|
59
|
+
|
|
60
|
+
self.dispatcher = Dispatcher(registry, self.config)
|
|
61
|
+
|
|
62
|
+
self.logger.info("Consumer initialized successfully")
|
|
63
|
+
|
|
64
|
+
def start(self):
|
|
65
|
+
self.logger.info(f"Consumer started for stage: {self.config.stage}")
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
while self.running:
|
|
69
|
+
records = self.consumer.poll(timeout_ms=1000)
|
|
70
|
+
|
|
71
|
+
for tp, messages in records.items():
|
|
72
|
+
for message in messages:
|
|
73
|
+
try:
|
|
74
|
+
event = KafkaEvent(**message.value)
|
|
75
|
+
self.logger.debug(f"Processing event: {event.eventId}")
|
|
76
|
+
|
|
77
|
+
self.dispatcher.dispatch(event)
|
|
78
|
+
|
|
79
|
+
if not self.config.enable_auto_commit:
|
|
80
|
+
self.consumer.commit()
|
|
81
|
+
|
|
82
|
+
except Exception as e:
|
|
83
|
+
event_id = message.value.get("eventId", "unknown")
|
|
84
|
+
self.logger.error(
|
|
85
|
+
f"Error processing event {event_id}: {e}"
|
|
86
|
+
)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
except KeyboardInterrupt:
|
|
90
|
+
self.logger.info("Keyboard interrupt received. Shutting down...")
|
|
91
|
+
|
|
92
|
+
except Exception as e:
|
|
93
|
+
self.logger.error(f"Unexpected error in consumer: {e}")
|
|
94
|
+
|
|
95
|
+
finally:
|
|
96
|
+
self.consumer.close()
|
|
97
|
+
self.logger.info("Consumer closed.")
|
|
98
|
+
|
|
99
|
+
def stop(self):
|
|
100
|
+
self.running = False
|
|
101
|
+
self.logger.info("Stop signal received.")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from .idempotency import IdempotencyStore
|
|
2
|
+
from .retry import retry_with_backoff
|
|
3
|
+
from .utils import get_logger
|
|
4
|
+
from .exceptions import HandlerNotFoundError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Dispatcher:
|
|
8
|
+
|
|
9
|
+
def __init__(self, registry, config):
|
|
10
|
+
self.registry = registry
|
|
11
|
+
self.config = config
|
|
12
|
+
self.logger = get_logger(f"Dispatcher.{config.stage or 'default'}")
|
|
13
|
+
self.idempotency_store = IdempotencyStore()
|
|
14
|
+
|
|
15
|
+
def dispatch(self, event):
|
|
16
|
+
"""Dispatch event to appropriate handler with retry and idempotency."""
|
|
17
|
+
# Check for duplicate events
|
|
18
|
+
if self.config.enable_idempotency and self.idempotency_store.is_duplicate( self.config.group_id, event.eventId ):
|
|
19
|
+
self.logger.debug(f"Duplicate event detected: {event.eventId}")
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
handler = self.registry.get_handler(
|
|
23
|
+
event.eventType,
|
|
24
|
+
self.config.stage
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if not handler:
|
|
28
|
+
error_msg = f"No handler found for {event.eventType} @ {self.config.stage}"
|
|
29
|
+
self.logger.warning(error_msg)
|
|
30
|
+
raise HandlerNotFoundError(error_msg)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
if self.config.enable_retry:
|
|
34
|
+
retry_with_backoff(handler, event, event.maxRetries)
|
|
35
|
+
else:
|
|
36
|
+
handler(event)
|
|
37
|
+
|
|
38
|
+
self.logger.info(f"Successfully processed event: {event.eventId}")
|
|
39
|
+
|
|
40
|
+
except Exception as e:
|
|
41
|
+
self.logger.error(f"Failed to process event {event.eventId}: {e}")
|
|
42
|
+
|
|
43
|
+
# Send to dead letter queue if configured
|
|
44
|
+
if self.config.dead_letter_topic:
|
|
45
|
+
self._send_to_dlq(event, str(e))
|
|
46
|
+
else:
|
|
47
|
+
raise
|
|
48
|
+
|
|
49
|
+
def _send_to_dlq(self, event, error_message):
|
|
50
|
+
"""Send failed event to dead letter queue."""
|
|
51
|
+
try:
|
|
52
|
+
from .producer import Producer
|
|
53
|
+
|
|
54
|
+
# Add error information to event metadata
|
|
55
|
+
if event.metadata is None:
|
|
56
|
+
event.metadata = {}
|
|
57
|
+
event.metadata['error'] = error_message
|
|
58
|
+
event.metadata['failed_stage'] = self.config.stage
|
|
59
|
+
event.metadata['failure_time'] = str(event.timestamp)
|
|
60
|
+
|
|
61
|
+
producer = Producer(self.config)
|
|
62
|
+
producer.send(self.config.dead_letter_topic, event)
|
|
63
|
+
producer.close()
|
|
64
|
+
|
|
65
|
+
self.logger.info(f"Sent event {event.eventId} to DLQ: {self.config.dead_letter_topic}")
|
|
66
|
+
|
|
67
|
+
except Exception as dlq_error:
|
|
68
|
+
self.logger.error(f"Failed to send event {event.eventId} to DLQ: {dlq_error}")
|
|
69
|
+
raise
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class QueueSDKError(Exception):
|
|
2
|
+
"""Base exception for all Queue SDK errors."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConfigurationError(QueueSDKError):
|
|
7
|
+
"""Raised when there's a configuration issue."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HandlerNotFoundError(QueueSDKError):
|
|
12
|
+
"""Raised when no handler is found for an event type/stage combination."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EventProcessingError(QueueSDKError):
|
|
17
|
+
"""Raised when event processing fails."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProducerError(QueueSDKError):
|
|
22
|
+
"""Raised when producer operations fail."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConsumerError(QueueSDKError):
|
|
27
|
+
"""Raised when consumer operations fail."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SerializationError(QueueSDKError):
|
|
32
|
+
"""Raised when event serialization/deserialization fails."""
|
|
33
|
+
pass
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class IdempotencyStore:
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self.processed_ids = set()
|
|
7
|
+
self.lock = threading.Lock()
|
|
8
|
+
|
|
9
|
+
def is_duplicate(self, group_id: str, event_id: str) -> bool:
|
|
10
|
+
key = f"{group_id}:{event_id}"
|
|
11
|
+
|
|
12
|
+
with self.lock:
|
|
13
|
+
if key in self.processed_ids:
|
|
14
|
+
return True
|
|
15
|
+
|
|
16
|
+
self.processed_ids.add(key)
|
|
17
|
+
return False
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class KafkaEvent(BaseModel):
|
|
8
|
+
eventId: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
9
|
+
eventType: str
|
|
10
|
+
eventVersion: str
|
|
11
|
+
source: str
|
|
12
|
+
tenantId: str
|
|
13
|
+
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
|
14
|
+
|
|
15
|
+
correlationId: Optional[str] = None
|
|
16
|
+
traceId: Optional[str] = None
|
|
17
|
+
|
|
18
|
+
priority: Optional[str] = "MEDIUM"
|
|
19
|
+
retryCount: int = 0
|
|
20
|
+
maxRetries: int = 5
|
|
21
|
+
|
|
22
|
+
payload: Dict[str, Any]
|
|
23
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import uuid
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from kafka import KafkaProducer
|
|
5
|
+
from .models import KafkaEvent
|
|
6
|
+
from .utils import get_logger
|
|
7
|
+
from .exceptions import ProducerError
|
|
8
|
+
from typing import Union, List
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Producer:
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.logger = get_logger("Producer")
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
self.producer = KafkaProducer(
|
|
17
|
+
bootstrap_servers="localhost:9092",
|
|
18
|
+
value_serializer=lambda v: json.dumps(v, default=str).encode("utf-8"),
|
|
19
|
+
key_serializer=lambda k: k.encode("utf-8") if k else None,
|
|
20
|
+
acks="all",
|
|
21
|
+
retries=5,
|
|
22
|
+
enable_idempotence=True
|
|
23
|
+
)
|
|
24
|
+
self.logger.info("Producer initialized successfully")
|
|
25
|
+
except Exception as e:
|
|
26
|
+
self.logger.error(f"Failed to initialize producer: {e}")
|
|
27
|
+
raise ProducerError(f"Producer initialization failed: {e}")
|
|
28
|
+
|
|
29
|
+
def send(self, topic: Union[str, List[str]], message: dict) -> None:
|
|
30
|
+
try:
|
|
31
|
+
# Create KafkaEvent internally
|
|
32
|
+
event = KafkaEvent(
|
|
33
|
+
eventId=str(uuid.uuid4()),
|
|
34
|
+
eventType=message.get("eventType"),
|
|
35
|
+
eventVersion=message.get("eventVersion", "1.0"),
|
|
36
|
+
source=message.get("source"),
|
|
37
|
+
tenantId=message.get("tenantId"),
|
|
38
|
+
timestamp=datetime.now(timezone.utc),
|
|
39
|
+
correlationId=str(uuid.uuid4()),
|
|
40
|
+
traceId=str(uuid.uuid4()),
|
|
41
|
+
priority=message.get("priority", "MEDIUM"),
|
|
42
|
+
payload=message.get("payload", {}),
|
|
43
|
+
metadata=message.get("metadata", {})
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
event_dict = event.model_dump(mode="json")
|
|
47
|
+
|
|
48
|
+
topics = [topic] if isinstance(topic, str) else topic
|
|
49
|
+
|
|
50
|
+
for t in topics:
|
|
51
|
+
self.producer.send(t, value=event_dict, key=event.eventId)
|
|
52
|
+
|
|
53
|
+
except Exception as e:
|
|
54
|
+
self.logger.error(f"Failed to send event: {e}")
|
|
55
|
+
raise ProducerError(f"Failed to send event: {e}")
|
|
56
|
+
|
|
57
|
+
def flush(self):
|
|
58
|
+
"""Flush pending messages."""
|
|
59
|
+
try:
|
|
60
|
+
self.producer.flush()
|
|
61
|
+
self.logger.debug("Producer flushed successfully")
|
|
62
|
+
except Exception as e:
|
|
63
|
+
self.logger.error(f"Failed to flush producer: {e}")
|
|
64
|
+
raise ProducerError(f"Failed to flush producer: {e}")
|
|
65
|
+
|
|
66
|
+
def close(self):
|
|
67
|
+
"""Close producer and clean up resources."""
|
|
68
|
+
try:
|
|
69
|
+
self.producer.flush()
|
|
70
|
+
self.producer.close()
|
|
71
|
+
self.logger.info("Producer closed successfully")
|
|
72
|
+
except Exception as e:
|
|
73
|
+
self.logger.error(f"Error closing producer: {e}")
|
|
74
|
+
raise ProducerError(f"Error closing producer: {e}")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class EventRegistry:
|
|
2
|
+
def __init__(self):
|
|
3
|
+
self._handlers = {}
|
|
4
|
+
|
|
5
|
+
def register(self, event_type, handler, stage=None):
|
|
6
|
+
key = (event_type, stage)
|
|
7
|
+
self._handlers[key] = handler
|
|
8
|
+
|
|
9
|
+
def get_handler(self, event_type, stage=None):
|
|
10
|
+
return self._handlers.get((event_type, stage))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
registry = EventRegistry()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Callable, Any
|
|
3
|
+
from .utils import get_logger
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def retry_with_backoff(handler: Callable[[Any], Any], event: Any, max_retries: int,
|
|
7
|
+
base_delay: float = 1.0, max_delay: float = 60.0) -> Any:
|
|
8
|
+
"""
|
|
9
|
+
Retry handler with exponential backoff.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
handler: The function to retry
|
|
13
|
+
event: The event to pass to the handler
|
|
14
|
+
max_retries: Maximum number of retry attempts
|
|
15
|
+
base_delay: Initial delay between retries (seconds)
|
|
16
|
+
max_delay: Maximum delay between retries (seconds)
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Result from handler function
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
MaxRetriesExceededError: If all retries are exhausted
|
|
23
|
+
"""
|
|
24
|
+
logger = get_logger("Retry")
|
|
25
|
+
|
|
26
|
+
while event.retryCount < max_retries:
|
|
27
|
+
try:
|
|
28
|
+
return handler(event)
|
|
29
|
+
except Exception as e:
|
|
30
|
+
event.retryCount += 1
|
|
31
|
+
|
|
32
|
+
if event.retryCount >= max_retries:
|
|
33
|
+
logger.error(f"Max retries ({max_retries}) exceeded for event {event.eventId}")
|
|
34
|
+
raise MaxRetriesExceededError(f"Failed after {max_retries} retries: {e}")
|
|
35
|
+
|
|
36
|
+
# Calculate exponential backoff with jitter
|
|
37
|
+
delay = min(base_delay * (2 ** (event.retryCount - 1)), max_delay)
|
|
38
|
+
jitter = delay * 0.1 * (0.5 - time.time() % 1) # ±10% jitter
|
|
39
|
+
final_delay = delay + jitter
|
|
40
|
+
|
|
41
|
+
logger.warning(f"Retry {event.retryCount}/{max_retries} for event {event.eventId} "
|
|
42
|
+
f"after {final_delay:.2f}s. Error: {e}")
|
|
43
|
+
time.sleep(final_delay)
|
|
44
|
+
|
|
45
|
+
raise MaxRetriesExceededError(f"Failed after {max_retries} retries")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_logger(name: str, level: Optional[int] = None) -> logging.Logger:
|
|
6
|
+
"""Get a configured logger with consistent formatting."""
|
|
7
|
+
logger = logging.getLogger(name)
|
|
8
|
+
|
|
9
|
+
if not logger.handlers:
|
|
10
|
+
handler = logging.StreamHandler()
|
|
11
|
+
formatter = logging.Formatter(
|
|
12
|
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
13
|
+
)
|
|
14
|
+
handler.setFormatter(formatter)
|
|
15
|
+
logger.addHandler(handler)
|
|
16
|
+
|
|
17
|
+
if level:
|
|
18
|
+
logger.setLevel(level)
|
|
19
|
+
elif logger.level == logging.NOTSET:
|
|
20
|
+
logger.setLevel(logging.INFO)
|
|
21
|
+
|
|
22
|
+
return logger
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sify-queue-kafka
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Enterprise Queue management Framework
|
|
5
5
|
Author: sifymodernization
|
|
6
6
|
Author-email: sifymodernization <sifymodernization.dev@sifycorp.com>
|
|
@@ -192,32 +192,33 @@ if __name__ == "__main__":
|
|
|
192
192
|
|
|
193
193
|
| Option | Type | Default | Description |
|
|
194
194
|
|--------|------|---------|-------------|
|
|
195
|
-
| `bootstrap_servers` | str |
|
|
196
|
-
| `group_id` | str |
|
|
195
|
+
| `bootstrap_servers` | str | "localhost:9092" | Kafka bootstrap servers |
|
|
196
|
+
| `group_id` | str | "" | Consumer group ID. Is generated with the tenant_id and source from the consumer config |
|
|
197
197
|
| `enable_auto_commit` | bool | False | Enable auto commit of offsets |
|
|
198
198
|
| `auto_offset_reset` | str | "earliest" | Offset reset policy |
|
|
199
199
|
| `enable_idempotency` | bool | True | Enable duplicate detection |
|
|
200
200
|
| `enable_retry` | bool | True | Enable retry with backoff |
|
|
201
201
|
| `dead_letter_topic` | str | None | Topic for failed events |
|
|
202
|
-
| `stage` | str |
|
|
202
|
+
| `stage` | str | "" | Processing stage name |
|
|
203
|
+
| `enable_idempotent_producer` | bool | False | Enable idempotent producer |
|
|
203
204
|
|
|
204
205
|
## Event Model
|
|
205
206
|
|
|
206
207
|
```python
|
|
207
208
|
class KafkaEvent(BaseModel):
|
|
208
|
-
eventId: str
|
|
209
|
+
eventId: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
209
210
|
eventType: str
|
|
210
211
|
eventVersion: str
|
|
211
212
|
source: str
|
|
212
213
|
tenantId: str
|
|
213
|
-
timestamp: datetime
|
|
214
|
-
correlationId: Optional[str]
|
|
215
|
-
traceId: Optional[str]
|
|
216
|
-
priority: Optional[str]
|
|
217
|
-
retryCount: int
|
|
218
|
-
maxRetries: int
|
|
214
|
+
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
|
215
|
+
correlationId: Optional[str] = None
|
|
216
|
+
traceId: Optional[str] = None
|
|
217
|
+
priority: Optional[str] = "MEDIUM"
|
|
218
|
+
retryCount: int = 0
|
|
219
|
+
maxRetries: int = 5
|
|
219
220
|
payload: Dict[str, Any]
|
|
220
|
-
metadata: Optional[Dict[str, Any]]
|
|
221
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
221
222
|
```
|
|
222
223
|
|
|
223
224
|
## Exception Types
|
|
@@ -260,7 +261,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|
|
260
261
|
|
|
261
262
|
## Changelog
|
|
262
263
|
|
|
263
|
-
### v0.1.
|
|
264
|
+
### v0.1.4
|
|
264
265
|
- Initial release
|
|
265
266
|
- Basic producer/consumer functionality
|
|
266
267
|
- Event validation with Pydantic
|