aws-python-helper 0.23.0__py3-none-any.whl
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.
- aws_python_helper/__init__.py +45 -0
- aws_python_helper/api/__init__.py +11 -0
- aws_python_helper/api/auth_middleware.py +108 -0
- aws_python_helper/api/auth_validators.py +143 -0
- aws_python_helper/api/base.py +272 -0
- aws_python_helper/api/dispatcher.py +213 -0
- aws_python_helper/api/exceptions.py +43 -0
- aws_python_helper/api/fetcher.py +210 -0
- aws_python_helper/api/handler.py +106 -0
- aws_python_helper/database/__init__.py +11 -0
- aws_python_helper/database/database_proxy.py +50 -0
- aws_python_helper/database/external_database_proxy.py +66 -0
- aws_python_helper/database/external_mongo_manager.py +212 -0
- aws_python_helper/database/mongo_manager.py +214 -0
- aws_python_helper/fargate/__init__.py +9 -0
- aws_python_helper/fargate/executor.py +226 -0
- aws_python_helper/fargate/fetcher.py +108 -0
- aws_python_helper/fargate/handler.py +101 -0
- aws_python_helper/fargate/task_base.py +165 -0
- aws_python_helper/lambda_standalone/__init__.py +8 -0
- aws_python_helper/lambda_standalone/base.py +171 -0
- aws_python_helper/lambda_standalone/fetcher.py +122 -0
- aws_python_helper/lambda_standalone/handler.py +117 -0
- aws_python_helper/sns/__init__.py +6 -0
- aws_python_helper/sns/publisher.py +245 -0
- aws_python_helper/sqs/__init__.py +10 -0
- aws_python_helper/sqs/consumer_base.py +416 -0
- aws_python_helper/sqs/fetcher.py +111 -0
- aws_python_helper/sqs/handler.py +138 -0
- aws_python_helper/utils/__init__.py +9 -0
- aws_python_helper/utils/json_encoder.py +108 -0
- aws_python_helper/utils/response.py +145 -0
- aws_python_helper/utils/serializer.py +103 -0
- aws_python_helper-0.23.0.dist-info/METADATA +712 -0
- aws_python_helper-0.23.0.dist-info/RECORD +37 -0
- aws_python_helper-0.23.0.dist-info/WHEEL +5 -0
- aws_python_helper-0.23.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQS Consumer Base - Base class for all SQS consumers
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Dict, Any, List
|
|
7
|
+
import logging
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
from ..database.mongo_manager import MongoManager
|
|
11
|
+
from ..database.database_proxy import DatabaseProxy
|
|
12
|
+
from ..database.external_mongo_manager import ExternalMongoManager
|
|
13
|
+
from ..database.external_database_proxy import ExternalDatabaseProxy
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SQSConsumer(ABC):
|
|
17
|
+
"""
|
|
18
|
+
Base class for all SQS consumers
|
|
19
|
+
|
|
20
|
+
Provides structure to process SQS messages in batch or individually.
|
|
21
|
+
|
|
22
|
+
Processing modes:
|
|
23
|
+
- "single": Process messages one by one using process_record()
|
|
24
|
+
- "batch": Process all messages together using process_batch()
|
|
25
|
+
|
|
26
|
+
Error Handling & Retries:
|
|
27
|
+
Both modes support controlled retry via AWS SQS reportBatchItemFailures:
|
|
28
|
+
- "single" mode: If process_record() raises an exception, that message
|
|
29
|
+
is automatically reported for retry. Other messages continue processing.
|
|
30
|
+
- "batch" mode: Use add_message_failed() to mark failed messages.
|
|
31
|
+
The handler will automatically create reportBatchItemFailures so AWS SQS
|
|
32
|
+
retries only those specific messages. Your _do_process_batch() is automatically
|
|
33
|
+
wrapped with error handling.
|
|
34
|
+
|
|
35
|
+
This ensures that if 1 message fails out of 5, only that 1 message is retried,
|
|
36
|
+
not the entire batch.
|
|
37
|
+
|
|
38
|
+
Usage:
|
|
39
|
+
# Single mode (default):
|
|
40
|
+
class MyConsumer(SQSConsumer):
|
|
41
|
+
@property
|
|
42
|
+
def processing_mode(self):
|
|
43
|
+
return "single" # or omit, "single" is default
|
|
44
|
+
|
|
45
|
+
async def process_record(self, record):
|
|
46
|
+
# Process individual record
|
|
47
|
+
# If you raise an exception here, AWS SQS will retry only this message
|
|
48
|
+
# Other messages in the batch will continue processing normally
|
|
49
|
+
body = self.parse_body(record)
|
|
50
|
+
# Your processing logic here
|
|
51
|
+
# If something fails, just raise an exception:
|
|
52
|
+
# if error_condition:
|
|
53
|
+
# raise ValueError("This message failed, will be retried")
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
# Batch mode:
|
|
57
|
+
class MyBatchConsumer(SQSConsumer):
|
|
58
|
+
@property
|
|
59
|
+
def processing_mode(self):
|
|
60
|
+
return "batch"
|
|
61
|
+
|
|
62
|
+
async def process_batch(self, records):
|
|
63
|
+
# Process all records together
|
|
64
|
+
# Use add_message_failed() to mark failed messages - no need for try-except!
|
|
65
|
+
# The base class wraps this method with error handling automatically
|
|
66
|
+
for record in records:
|
|
67
|
+
message_id = record.get('messageId')
|
|
68
|
+
try:
|
|
69
|
+
# Your batch processing logic here
|
|
70
|
+
# Process all records together (bulk operations, transactions, etc.)
|
|
71
|
+
await self.process_message(record)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
# Mark as failed - automatically handled by base class
|
|
74
|
+
self.add_message_failed(message_id, str(e))
|
|
75
|
+
# No need to return results - base class handles it automatically
|
|
76
|
+
# But you can return results if you want more control
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self):
|
|
80
|
+
"""Initialize the consumer with a logger"""
|
|
81
|
+
self.logger = logging.getLogger(self.__class__.__name__)
|
|
82
|
+
self._db = None
|
|
83
|
+
self._external_db = None
|
|
84
|
+
self._failed_messages = [] # Track failed messages for batch mode
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def processing_mode(self) -> str:
|
|
88
|
+
"""
|
|
89
|
+
Processing mode: "single" or "batch"
|
|
90
|
+
|
|
91
|
+
- "single": Process messages individually using process_record()
|
|
92
|
+
- "batch": Process all messages together using process_batch()
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
"single" or "batch" (default: "single")
|
|
96
|
+
"""
|
|
97
|
+
return "single"
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def db(self):
|
|
101
|
+
"""
|
|
102
|
+
Access to MongoDB databases (main cluster)
|
|
103
|
+
|
|
104
|
+
Usage:
|
|
105
|
+
result = await self.db.users_db.users.find_one({'_id': user_id})
|
|
106
|
+
"""
|
|
107
|
+
if self._db is None:
|
|
108
|
+
self._db = DatabaseProxy(MongoManager)
|
|
109
|
+
return self._db
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def external_db(self):
|
|
113
|
+
"""
|
|
114
|
+
Access to external MongoDB clusters
|
|
115
|
+
|
|
116
|
+
Returns None if EXTERNAL_MONGODB_CONNECTIONS environment variable is not set.
|
|
117
|
+
|
|
118
|
+
Usage:
|
|
119
|
+
if self.external_db:
|
|
120
|
+
result = await self.external_db.ClusterDockets.smart_data.addresses.find_one({...})
|
|
121
|
+
await self.external_db.ClusterDockets.core.users.insert_one({...})
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
ExternalDatabaseProxy instance for accessing external clusters, or None if not configured
|
|
125
|
+
"""
|
|
126
|
+
if self._external_db is None:
|
|
127
|
+
# Initialize external connections if not already done
|
|
128
|
+
if not ExternalMongoManager.is_initialized():
|
|
129
|
+
has_connections = ExternalMongoManager.initialize()
|
|
130
|
+
if not has_connections:
|
|
131
|
+
# No external connections available, return None
|
|
132
|
+
return None
|
|
133
|
+
else:
|
|
134
|
+
# Check if there are any connections available
|
|
135
|
+
if len(ExternalMongoManager.get_available_clusters()) == 0:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
self._external_db = ExternalDatabaseProxy()
|
|
139
|
+
return self._external_db
|
|
140
|
+
|
|
141
|
+
async def process_record(self, record: Dict[str, Any]):
|
|
142
|
+
"""
|
|
143
|
+
Process an individual SQS record
|
|
144
|
+
|
|
145
|
+
This method is required when processing_mode is "single".
|
|
146
|
+
When processing_mode is "batch", this method is optional.
|
|
147
|
+
|
|
148
|
+
If this method raises an exception, AWS SQS will automatically retry
|
|
149
|
+
only that specific message. Other messages in the batch will continue
|
|
150
|
+
processing normally.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
record: SQS record with 'body', 'messageId', etc.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
Exception: Any error in the processing. The exception will be caught
|
|
157
|
+
and the message will be reported for retry by AWS SQS.
|
|
158
|
+
"""
|
|
159
|
+
# Only raise NotImplementedError if in single mode
|
|
160
|
+
if self.processing_mode == "single":
|
|
161
|
+
raise NotImplementedError(
|
|
162
|
+
f"You must implement process_record() when processing_mode is 'single'"
|
|
163
|
+
)
|
|
164
|
+
# In batch mode, this method is optional
|
|
165
|
+
|
|
166
|
+
def extract_content_message(self, record: Dict[str, Any]) -> Dict[str, Any]:
|
|
167
|
+
"""
|
|
168
|
+
Parse the body of the SQS message
|
|
169
|
+
|
|
170
|
+
When SNS sends messages to SQS, the body has this structure:
|
|
171
|
+
{
|
|
172
|
+
"Type": "Notification",
|
|
173
|
+
"Message": "{...the actual message serialized as JSON string...}",
|
|
174
|
+
"MessageAttributes": {...}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
This method automatically detects SNS messages and extracts the actual content
|
|
178
|
+
from the "Message" field. If it's not an SNS message, it parses the body directly.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
record: SQS record with 'body' field
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Parsed body as dict. For SNS messages, returns the parsed content from the "Message" field.
|
|
185
|
+
For regular SQS messages, returns the parsed body directly.
|
|
186
|
+
"""
|
|
187
|
+
body = record.get('body', '{}')
|
|
188
|
+
|
|
189
|
+
# Parse the body string to JSON if needed
|
|
190
|
+
if isinstance(body, str):
|
|
191
|
+
try:
|
|
192
|
+
body_json = json.loads(body)
|
|
193
|
+
except json.JSONDecodeError:
|
|
194
|
+
self.logger.warning(f"Could not parse body as JSON: {body}")
|
|
195
|
+
return {'raw': body}
|
|
196
|
+
else:
|
|
197
|
+
body_json = body
|
|
198
|
+
|
|
199
|
+
# Check if this is an SNS notification message
|
|
200
|
+
if isinstance(body_json, dict) and body_json.get('Type') == 'Notification':
|
|
201
|
+
# Extract the actual message from the "Message" field
|
|
202
|
+
message_str = body_json.get('Message', '{}')
|
|
203
|
+
|
|
204
|
+
if isinstance(message_str, str):
|
|
205
|
+
try:
|
|
206
|
+
# Parse the JSON string in the Message field
|
|
207
|
+
message_content = json.loads(message_str)
|
|
208
|
+
self.logger.debug(f"Extracted SNS message content: {message_content}")
|
|
209
|
+
return message_content
|
|
210
|
+
except json.JSONDecodeError:
|
|
211
|
+
self.logger.warning(f"Could not parse SNS Message field as JSON: {message_str}")
|
|
212
|
+
return {'raw': message_str, 'sns_notification': True}
|
|
213
|
+
else:
|
|
214
|
+
# Message field is already a dict
|
|
215
|
+
return message_str
|
|
216
|
+
|
|
217
|
+
# Not an SNS message, return the body as-is
|
|
218
|
+
return body_json
|
|
219
|
+
|
|
220
|
+
def add_message_failed(self, message_id: str, error: str = None):
|
|
221
|
+
"""
|
|
222
|
+
Mark a message as failed (for batch mode)
|
|
223
|
+
|
|
224
|
+
Use this method in your process_batch() implementation to mark messages
|
|
225
|
+
that failed during batch processing. The handler will automatically
|
|
226
|
+
create reportBatchItemFailures for these messages.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
message_id: The messageId of the failed message
|
|
230
|
+
error: Optional error message describing the failure
|
|
231
|
+
|
|
232
|
+
Usage:
|
|
233
|
+
async def process_batch(self, records):
|
|
234
|
+
for record in records:
|
|
235
|
+
message_id = record.get('messageId')
|
|
236
|
+
try:
|
|
237
|
+
# Your processing logic
|
|
238
|
+
process_message(record)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
# Mark as failed - no need to manually add to results
|
|
241
|
+
self.add_message_failed(message_id, str(e))
|
|
242
|
+
"""
|
|
243
|
+
self._failed_messages.append({
|
|
244
|
+
'messageId': message_id,
|
|
245
|
+
'itemIdentifier': message_id,
|
|
246
|
+
'error': error
|
|
247
|
+
})
|
|
248
|
+
if error:
|
|
249
|
+
self.logger.error(f"Message {message_id} marked as failed: {error}")
|
|
250
|
+
else:
|
|
251
|
+
self.logger.error(f"Message {message_id} marked as failed")
|
|
252
|
+
|
|
253
|
+
async def _process_batch_internal(self, records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
254
|
+
"""
|
|
255
|
+
Internal method called by the handler to process a batch of SQS records
|
|
256
|
+
|
|
257
|
+
This method routes to the appropriate processing based on processing_mode:
|
|
258
|
+
- "single": Processes records one by one using process_record()
|
|
259
|
+
- "batch": Wraps the user's process_batch() implementation with error handling
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
records: List of SQS records
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
List of results with success/error for each record
|
|
266
|
+
"""
|
|
267
|
+
mode = self.processing_mode
|
|
268
|
+
|
|
269
|
+
if mode == "batch":
|
|
270
|
+
# Reset failed messages list for this batch
|
|
271
|
+
self._failed_messages = []
|
|
272
|
+
# Wrap batch processing with error handling
|
|
273
|
+
return await self._process_batch_wrapper(records)
|
|
274
|
+
else:
|
|
275
|
+
# Single mode: process records one by one using process_record()
|
|
276
|
+
return await self._process_batch_single(records)
|
|
277
|
+
|
|
278
|
+
async def process_batch(self, records: List[Dict[str, Any]]) -> None:
|
|
279
|
+
"""
|
|
280
|
+
Process a batch of SQS records (to be overridden by user in batch mode)
|
|
281
|
+
|
|
282
|
+
When processing_mode is "batch", override this method to implement
|
|
283
|
+
your batch processing logic. Use add_message_failed() to mark failed messages.
|
|
284
|
+
The base class will wrap your implementation with error handling automatically.
|
|
285
|
+
|
|
286
|
+
This method should not return any value. Simply process the records and use
|
|
287
|
+
add_message_failed() to mark any messages that fail. All other messages will
|
|
288
|
+
be automatically marked as successful.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
records: List of SQS records
|
|
292
|
+
|
|
293
|
+
Example:
|
|
294
|
+
async def process_batch(self, records):
|
|
295
|
+
for record in records:
|
|
296
|
+
message_id = record.get('messageId')
|
|
297
|
+
try:
|
|
298
|
+
# Your processing logic
|
|
299
|
+
await self.process_message(record)
|
|
300
|
+
except Exception as e:
|
|
301
|
+
# Mark as failed - automatically handled
|
|
302
|
+
self.add_message_failed(message_id, str(e))
|
|
303
|
+
"""
|
|
304
|
+
# Base implementation - should be overridden by user when processing_mode is "batch"
|
|
305
|
+
# This method is only called when processing_mode is "batch"
|
|
306
|
+
# If not overridden, all messages will be marked as successful
|
|
307
|
+
# (unless marked as failed via add_message_failed)
|
|
308
|
+
if self.processing_mode == "batch":
|
|
309
|
+
raise NotImplementedError(
|
|
310
|
+
f"You must implement process_batch() when processing_mode is 'batch'"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
async def _process_batch_wrapper(self, records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
314
|
+
"""
|
|
315
|
+
Wrapper for batch mode processing with automatic error handling
|
|
316
|
+
|
|
317
|
+
This method wraps the user's process_batch() implementation with try-except
|
|
318
|
+
to catch any unhandled exceptions. It also merges failed messages marked
|
|
319
|
+
with add_message_failed() into the results.
|
|
320
|
+
|
|
321
|
+
Internal method used when processing_mode is "batch"
|
|
322
|
+
"""
|
|
323
|
+
# Get all message IDs upfront
|
|
324
|
+
message_ids = [record.get('messageId', f'record-{i}') for i, record in enumerate(records)]
|
|
325
|
+
results = []
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
# Call the user's process_batch implementation (no return value expected)
|
|
329
|
+
await self.process_batch(records)
|
|
330
|
+
|
|
331
|
+
# Build results based on _failed_messages
|
|
332
|
+
# All messages are successful by default, except those marked as failed
|
|
333
|
+
for message_id in message_ids:
|
|
334
|
+
# Check if this message was marked as failed
|
|
335
|
+
failed_msg = next((fm for fm in self._failed_messages if fm['messageId'] == message_id), None)
|
|
336
|
+
if failed_msg:
|
|
337
|
+
# Message was marked as failed
|
|
338
|
+
results.append({
|
|
339
|
+
'messageId': message_id,
|
|
340
|
+
'success': False,
|
|
341
|
+
'error': failed_msg.get('error'),
|
|
342
|
+
'itemIdentifier': failed_msg['itemIdentifier']
|
|
343
|
+
})
|
|
344
|
+
else:
|
|
345
|
+
# Message was successful
|
|
346
|
+
results.append({
|
|
347
|
+
'messageId': message_id,
|
|
348
|
+
'success': True
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
except Exception as e:
|
|
352
|
+
# If the entire batch processing fails, mark all messages as failed
|
|
353
|
+
self.logger.exception(f"Unhandled exception in process_batch: {e}")
|
|
354
|
+
for message_id in message_ids:
|
|
355
|
+
results.append({
|
|
356
|
+
'messageId': message_id,
|
|
357
|
+
'success': False,
|
|
358
|
+
'error': str(e),
|
|
359
|
+
'itemIdentifier': message_id
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
# Log summary
|
|
363
|
+
success_count = sum(1 for r in results if r.get('success', False))
|
|
364
|
+
failed_count = len(results) - success_count
|
|
365
|
+
self.logger.info(
|
|
366
|
+
f"Batch processing complete: {success_count} successful, {failed_count} failed"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
return results
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
async def _process_batch_single(self, records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
373
|
+
"""
|
|
374
|
+
Process records one by one (single mode)
|
|
375
|
+
|
|
376
|
+
If a message fails (raises an exception), it will be reported as failed
|
|
377
|
+
and AWS SQS will retry only that message. Other messages continue processing.
|
|
378
|
+
|
|
379
|
+
Internal method used when processing_mode is "single"
|
|
380
|
+
"""
|
|
381
|
+
results = []
|
|
382
|
+
|
|
383
|
+
for i, record in enumerate(records):
|
|
384
|
+
message_id = record.get('messageId', f'record-{i}')
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
self.logger.info(f"Processing message: {message_id}")
|
|
388
|
+
await self.process_record(record)
|
|
389
|
+
results.append({
|
|
390
|
+
'messageId': message_id,
|
|
391
|
+
'success': True
|
|
392
|
+
})
|
|
393
|
+
self.logger.info(f"Successfully processed message: {message_id}")
|
|
394
|
+
|
|
395
|
+
except Exception as e:
|
|
396
|
+
# Log the error but continue with other messages
|
|
397
|
+
self.logger.error(
|
|
398
|
+
f"Error processing message {message_id}: {e}",
|
|
399
|
+
exc_info=True
|
|
400
|
+
)
|
|
401
|
+
results.append({
|
|
402
|
+
'messageId': message_id,
|
|
403
|
+
'success': False,
|
|
404
|
+
'error': str(e),
|
|
405
|
+
'itemIdentifier': message_id # For reportBatchItemFailures (AWS SQS messageId)
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
# Log summary
|
|
409
|
+
success_count = sum(1 for r in results if r['success'])
|
|
410
|
+
failed_count = len(results) - success_count
|
|
411
|
+
self.logger.info(
|
|
412
|
+
f"Batch processing complete: {success_count} successful, {failed_count} failed"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
return results
|
|
416
|
+
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQS Fetcher - Dynamically load consumers based on name
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import importlib.util
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
class SQSFetcher:
|
|
10
|
+
"""
|
|
11
|
+
Dynamically load SQS consumers
|
|
12
|
+
|
|
13
|
+
Searches for consumers as direct files in 'src/consumer/' folder.
|
|
14
|
+
|
|
15
|
+
Convention:
|
|
16
|
+
consumer-name -> src/consumer/consumer_name.py -> ConsumerNameConsumer
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
'user-created' -> src/consumer/user_created.py -> UserCreatedConsumer
|
|
20
|
+
'title-indexed' -> src/consumer/title_indexed.py -> TitleIndexedConsumer
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
CONSUMERS_FOLDER = "consumer"
|
|
24
|
+
_cache = {}
|
|
25
|
+
|
|
26
|
+
def __init__(self, consumer_name: str):
|
|
27
|
+
"""
|
|
28
|
+
Initialize the fetcher
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
consumer_name: Name of the consumer in kebab-case (e.g.: 'user-created')
|
|
32
|
+
"""
|
|
33
|
+
self.consumer_name = consumer_name
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def file_path(self) -> str:
|
|
37
|
+
"""
|
|
38
|
+
Calculate the path of the consumer file
|
|
39
|
+
|
|
40
|
+
Converts 'user-created' to 'user_created.py'
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Absolute path to the consumer file
|
|
44
|
+
"""
|
|
45
|
+
# Convert dashes to underscores for Python file name
|
|
46
|
+
file_name = self.consumer_name.replace('-', '_') + '_consumer.py'
|
|
47
|
+
|
|
48
|
+
base_path = Path(os.getcwd()) / self.CONSUMERS_FOLDER
|
|
49
|
+
file_path = base_path / file_name
|
|
50
|
+
|
|
51
|
+
return str(file_path)
|
|
52
|
+
|
|
53
|
+
def get_consumer(self):
|
|
54
|
+
"""
|
|
55
|
+
Load and return an instance of the consumer
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Instance of the consumer
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
FileNotFoundError: If the file does not exist
|
|
62
|
+
ValueError: If the consumer class is not valid
|
|
63
|
+
"""
|
|
64
|
+
file_path = self.file_path
|
|
65
|
+
|
|
66
|
+
# Verify cache
|
|
67
|
+
if file_path in self._cache:
|
|
68
|
+
return self._cache[file_path]()
|
|
69
|
+
|
|
70
|
+
# Verify that the file exists
|
|
71
|
+
if not os.path.exists(file_path):
|
|
72
|
+
raise FileNotFoundError(
|
|
73
|
+
f"Consumer not found: {file_path}\n"
|
|
74
|
+
f"Expected file for consumer '{self.consumer_name}' at {file_path}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Load module dynamically
|
|
78
|
+
spec = importlib.util.spec_from_file_location("consumer_module", file_path)
|
|
79
|
+
if not spec or not spec.loader:
|
|
80
|
+
raise ImportError(f"Could not load module spec from: {file_path}")
|
|
81
|
+
|
|
82
|
+
module = importlib.util.module_from_spec(spec)
|
|
83
|
+
spec.loader.exec_module(module)
|
|
84
|
+
|
|
85
|
+
# Search for class that inherits from SQSConsumer
|
|
86
|
+
# Expected class name: 'user-created' -> 'UserCreatedConsumer'
|
|
87
|
+
class_name = ''.join(
|
|
88
|
+
word.capitalize() for word in self.consumer_name.split('-')
|
|
89
|
+
) + 'Consumer'
|
|
90
|
+
|
|
91
|
+
consumer_class = None
|
|
92
|
+
for item_name in dir(module):
|
|
93
|
+
item = getattr(module, item_name)
|
|
94
|
+
if (isinstance(item, type) and
|
|
95
|
+
hasattr(item, 'process_record') and
|
|
96
|
+
item.__name__ not in ['SQSConsumer', 'ABC']):
|
|
97
|
+
consumer_class = item
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
if not consumer_class:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"No SQSConsumer class found in {file_path}\n"
|
|
103
|
+
f"Make sure your file exports a class that inherits from SQSConsumer\n"
|
|
104
|
+
f"Expected class name: {class_name}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Cache the class
|
|
108
|
+
self._cache[file_path] = consumer_class
|
|
109
|
+
|
|
110
|
+
# Return new instance
|
|
111
|
+
return consumer_class()
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQS Handler - Generic reusable handler for SQS consumers
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Dict, Any, Callable
|
|
9
|
+
|
|
10
|
+
from .fetcher import SQSFetcher
|
|
11
|
+
from ..utils.serializer import serialize_mongo_types
|
|
12
|
+
from ..database.mongo_manager import MongoManager
|
|
13
|
+
|
|
14
|
+
def setup_logging():
|
|
15
|
+
root_logger = logging.getLogger()
|
|
16
|
+
root_logger.setLevel(logging.INFO)
|
|
17
|
+
|
|
18
|
+
# Solo configurar si no tiene handlers
|
|
19
|
+
if not root_logger.handlers:
|
|
20
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
21
|
+
handler.setFormatter(
|
|
22
|
+
logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
23
|
+
)
|
|
24
|
+
root_logger.addHandler(handler)
|
|
25
|
+
else:
|
|
26
|
+
# Si ya tiene handlers, solo actualizar el nivel
|
|
27
|
+
for handler in root_logger.handlers:
|
|
28
|
+
handler.setLevel(logging.INFO)
|
|
29
|
+
|
|
30
|
+
# Llamar al inicio del módulo
|
|
31
|
+
setup_logging()
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
def sqs_handler(consumer_name: str) -> Callable:
|
|
36
|
+
"""
|
|
37
|
+
Factory that returns a handler for a specific consumer
|
|
38
|
+
|
|
39
|
+
This pattern allows creating specific handlers for each consumer
|
|
40
|
+
while keeping the code DRY.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
consumer_name: Name of the consumer (must exist in consumer/)
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Configured handler function for that consumer
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
|
|
50
|
+
"""
|
|
51
|
+
Generic handler for AWS Lambda SQS
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
event: SQS event with Records
|
|
55
|
+
context: Lambda context
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Summary of the processing
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
# Initialize MongoDB connection (only once, reused in subsequent invocations)
|
|
62
|
+
try:
|
|
63
|
+
if not MongoManager.is_initialized():
|
|
64
|
+
MongoManager.initialize()
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.warning(f"MongoDB initialization skipped: {e}")
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
# Load consumer
|
|
70
|
+
fetcher = SQSFetcher(consumer_name)
|
|
71
|
+
consumer = fetcher.get_consumer()
|
|
72
|
+
|
|
73
|
+
# Get records
|
|
74
|
+
records = event.get('Records', [])
|
|
75
|
+
|
|
76
|
+
# Process batch
|
|
77
|
+
# Use get_event_loop() instead of asyncio.run() to avoid closing the loop
|
|
78
|
+
# This is important for AWS Lambda container reuse with Motor/MongoDB
|
|
79
|
+
try:
|
|
80
|
+
loop = asyncio.get_event_loop()
|
|
81
|
+
if loop.is_closed():
|
|
82
|
+
loop = asyncio.new_event_loop()
|
|
83
|
+
asyncio.set_event_loop(loop)
|
|
84
|
+
except RuntimeError:
|
|
85
|
+
loop = asyncio.new_event_loop()
|
|
86
|
+
asyncio.set_event_loop(loop)
|
|
87
|
+
|
|
88
|
+
results = loop.run_until_complete(consumer._process_batch_internal(records))
|
|
89
|
+
|
|
90
|
+
# Count successes and failures
|
|
91
|
+
success_count = sum(1 for r in results if r['success'])
|
|
92
|
+
error_count = len(results) - success_count
|
|
93
|
+
|
|
94
|
+
# Get failed message IDs for reportBatchItemFailures
|
|
95
|
+
# This allows AWS SQS to retry only failed messages, not the entire batch
|
|
96
|
+
failed_message_ids = [
|
|
97
|
+
r.get('itemIdentifier') or r.get('messageId')
|
|
98
|
+
for r in results
|
|
99
|
+
if not r.get('success', True)
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
response = {
|
|
103
|
+
'processed': len(results),
|
|
104
|
+
'successful': success_count,
|
|
105
|
+
'failed': error_count,
|
|
106
|
+
'results': results # Include detailed results
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# If there are failures, add reportBatchItemFailures for partial batch failure reporting
|
|
110
|
+
# This tells AWS SQS to only retry the failed messages
|
|
111
|
+
if failed_message_ids:
|
|
112
|
+
response['batchItemFailures'] = [
|
|
113
|
+
{'itemIdentifier': msg_id} for msg_id in failed_message_ids
|
|
114
|
+
]
|
|
115
|
+
logger.warning(
|
|
116
|
+
f"Processing complete with {error_count} failures. "
|
|
117
|
+
f"Failed messages will be retried: {failed_message_ids}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Serialize MongoDB types to JSON-serializable types
|
|
121
|
+
# AWS Lambda will serialize the return value to JSON, so we need to ensure
|
|
122
|
+
# all MongoDB types (ObjectId, datetime, etc.) are converted first
|
|
123
|
+
response = serialize_mongo_types(response)
|
|
124
|
+
|
|
125
|
+
return response
|
|
126
|
+
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.exception(f"Unhandled exception in SQS handler: {e}")
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
'processed': 0,
|
|
132
|
+
'successful': 0,
|
|
133
|
+
'failed': len(event.get('Records', [])),
|
|
134
|
+
'error': str(e)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return handler
|
|
138
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utils Module - Utilities for the framework
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .response import ApiResponse
|
|
6
|
+
from .json_encoder import MongoJSONEncoder, mongo_json_dumps
|
|
7
|
+
from .serializer import serialize_mongo_types
|
|
8
|
+
|
|
9
|
+
__all__ = ['MongoJSONEncoder', 'ApiResponse', 'mongo_json_dumps', 'serialize_mongo_types']
|