async-lambda-unstable 0.6.6__tar.gz → 0.6.8__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.
Files changed (30) hide show
  1. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/PKG-INFO +1 -1
  2. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/__init__.py +1 -1
  3. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/client.py +19 -1
  4. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/controller.py +193 -16
  5. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/env.py +47 -0
  6. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/.gitignore +0 -0
  7. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/README.md +0 -0
  8. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/build_config.py +0 -0
  9. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/cli.py +0 -0
  10. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/config.py +0 -0
  11. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/defer.py +0 -0
  12. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/middleware.py +0 -0
  13. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/models/__init__.py +0 -0
  14. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/models/api_response.py +0 -0
  15. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/models/case_insensitive_dict.py +0 -0
  16. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/models/events/__init__.py +0 -0
  17. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/models/events/api_event.py +0 -0
  18. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/models/events/base_event.py +0 -0
  19. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/models/events/dynamodb_event.py +0 -0
  20. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/models/events/managed_sqs_batch_event.py +0 -0
  21. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/models/events/managed_sqs_event.py +0 -0
  22. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/models/events/scheduled_event.py +0 -0
  23. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/models/events/unmanaged_sqs_event.py +0 -0
  24. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/models/mock/mock_context.py +0 -0
  25. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/models/mock/mock_event.py +0 -0
  26. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/models/task.py +0 -0
  27. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/payload_encoder.py +0 -0
  28. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/py.typed +0 -0
  29. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/async_lambda/util.py +0 -0
  30. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.8}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: async-lambda-unstable
3
- Version: 0.6.6
3
+ Version: 0.6.8
4
4
  Summary: A framework for creating AWS Lambda Async Workflows. - Unstable Branch
5
5
  Author-email: "Nuclei, Inc" <engineering@nuclei.ai>
6
6
  Requires-Dist: click>=8.0.0
@@ -21,4 +21,4 @@ from .models.events.managed_sqs_event import ManagedSQSEvent as ManagedSQSEvent
21
21
  from .models.events.scheduled_event import ScheduledEvent as ScheduledEvent
22
22
  from .models.events.unmanaged_sqs_event import UnmanagedSQSEvent as UnmanagedSQSEvent
23
23
 
24
- __version__ = "0.6.6"
24
+ __version__ = "0.6.8"
@@ -12,6 +12,7 @@ class Clients:
12
12
  s3_client (Optional[Any]): The AWS S3 client instance.
13
13
  sqs_client (Optional[Any]): The AWS SQS client instance.
14
14
  sts_client (Optional[Any]): The AWS STS client instance.
15
+ scheduler_client (Optional[Any]): The AWS EventBridge Scheduler client instance.
15
16
 
16
17
  Methods:
17
18
  reset():
@@ -21,14 +22,16 @@ class Clients:
21
22
  s3_client: Optional[Any] = None
22
23
  sqs_client: Optional[Any] = None
23
24
  sts_client: Optional[Any] = None
25
+ scheduler_client: Optional[Any] = None
24
26
 
25
27
  def reset(self):
26
28
  """
27
- Resets the AWS service clients (S3, SQS, STS) by setting them to None.
29
+ Resets the AWS service clients (S3, SQS, STS, Scheduler) by setting them to None.
28
30
  """
29
31
  self.s3_client = None
30
32
  self.sqs_client = None
31
33
  self.sts_client = None
34
+ self.scheduler_client = None
32
35
 
33
36
 
34
37
  clients = Clients()
@@ -92,3 +95,18 @@ def get_sts_client():
92
95
  if clients.sts_client is None:
93
96
  clients.sts_client = boto3.client("sts", **get_client_kwargs())
94
97
  return clients.sts_client
98
+
99
+
100
+ def get_scheduler_client():
101
+ """
102
+ Returns a cached AWS EventBridge Scheduler client instance.
103
+
104
+ If the client does not exist, it creates a new one using boto3 with the provided keyword arguments.
105
+ Subsequent calls will return the cached client.
106
+
107
+ Returns:
108
+ boto3.client: An AWS EventBridge Scheduler client instance.
109
+ """
110
+ if clients.scheduler_client is None:
111
+ clients.scheduler_client = boto3.client("scheduler", **get_client_kwargs())
112
+ return clients.scheduler_client
@@ -4,7 +4,7 @@ import logging
4
4
  import random
5
5
  import re
6
6
  import time
7
- from datetime import datetime, timezone
7
+ from datetime import datetime, timedelta, timezone
8
8
  from typing import (
9
9
  Any,
10
10
  Callable,
@@ -21,7 +21,7 @@ from uuid import uuid4
21
21
 
22
22
  from . import env
23
23
  from .build_config import get_build_config_for_stage
24
- from .client import get_s3_client, get_sqs_client
24
+ from .client import get_s3_client, get_scheduler_client, get_sqs_client
25
25
  from .config import config
26
26
  from .middleware import MET, RT, MiddlewareFunction, MiddlewareRegistration
27
27
  from .models.events.api_event import APIEvent
@@ -39,6 +39,8 @@ from .util import make_cf_tags
39
39
 
40
40
  logger = logging.getLogger(__name__)
41
41
 
42
+ _SQS_MAX_DELAY_SECONDS = 900
43
+
42
44
  BaseEventT = TypeVar("BaseEventT", bound=BaseEvent)
43
45
  APIEventT = TypeVar("APIEventT", bound=APIEvent)
44
46
  ManagedSQSEventT = TypeVar("ManagedSQSEventT", bound=ManagedSQSEvent)
@@ -423,6 +425,12 @@ class AsyncLambdaController:
423
425
  "Ref": "AsyncLambdaPayloadBucket"
424
426
  },
425
427
  "ASYNC_LAMBDA_ACCOUNT_ID": {"Ref": "AWS::AccountId"},
428
+ "ASYNC_LAMBDA_DELAY_SCHEDULER_ROLE_ARN": {
429
+ "Fn::GetAtt": ["AsyncLambdaDelaySchedulerRole", "Arn"]
430
+ },
431
+ "ASYNC_LAMBDA_DELAY_SCHEDULE_GROUP": {
432
+ "Ref": "AsyncLambdaDelayScheduleGroup"
433
+ },
426
434
  **build_config.environment_variables,
427
435
  },
428
436
  },
@@ -451,6 +459,63 @@ class AsyncLambdaController:
451
459
  ),
452
460
  },
453
461
  },
462
+ "AsyncLambdaDelayDLQ": {
463
+ "Type": "AWS::SQS::Queue",
464
+ "Properties": {
465
+ "QueueName": f"{config.name}-delay-dlq",
466
+ "MessageRetentionPeriod": 1_209_600, # 14 days
467
+ "Tags": make_cf_tags(
468
+ {
469
+ **build_config.tags,
470
+ "async-lambda-queue-type": "dlq",
471
+ }
472
+ ),
473
+ },
474
+ },
475
+ "AsyncLambdaDelayScheduleGroup": {
476
+ "Type": "AWS::Scheduler::ScheduleGroup",
477
+ "Properties": {
478
+ "Name": f"{config.name}-delay",
479
+ "Tags": make_cf_tags(build_config.tags),
480
+ },
481
+ },
482
+ "AsyncLambdaCreateDelaySchedulesPolicy": {
483
+ "Type": "AWS::IAM::ManagedPolicy",
484
+ "Properties": {
485
+ "ManagedPolicyName": {
486
+ "Fn::Sub": "${AWS::StackName}-create-delay-schedules"
487
+ },
488
+ "PolicyDocument": {
489
+ "Version": "2012-10-17",
490
+ "Statement": [
491
+ {
492
+ "Sid": "CreateSchedules",
493
+ "Effect": "Allow",
494
+ "Action": "scheduler:CreateSchedule",
495
+ "Resource": {
496
+ "Fn::Sub": "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AsyncLambdaDelayScheduleGroup}/*"
497
+ },
498
+ },
499
+ {
500
+ "Sid": "PassSchedulerExecutionRole",
501
+ "Effect": "Allow",
502
+ "Action": "iam:PassRole",
503
+ "Resource": {
504
+ "Fn::GetAtt": [
505
+ "AsyncLambdaDelaySchedulerRole",
506
+ "Arn",
507
+ ]
508
+ },
509
+ "Condition": {
510
+ "StringEquals": {
511
+ "iam:PassedToService": "scheduler.amazonaws.com"
512
+ }
513
+ },
514
+ },
515
+ ],
516
+ },
517
+ },
518
+ },
454
519
  },
455
520
  }
456
521
  _task_list = list(self.tasks.values())
@@ -466,13 +531,61 @@ class AsyncLambdaController:
466
531
  external_async_task_id
467
532
  )
468
533
  ]
469
- task_ref_policies = {}
470
- if len(managed_tasks_resources) > 0:
471
- task_ref_policies = self._build_send_to_all_async_lambda_queues_policies(
534
+
535
+ send_all_queues_task_ref_policies = (
536
+ self._build_send_to_all_async_lambda_queues_policies(
472
537
  managed_tasks_resources
473
538
  )
474
- for key in task_ref_policies.keys():
475
- template["Resources"][key] = task_ref_policies[key]
539
+ )
540
+ for key in send_all_queues_task_ref_policies.keys():
541
+ template["Resources"][key] = send_all_queues_task_ref_policies[key]
542
+
543
+ template["Resources"]["AsyncLambdaDelaySchedulerRole"] = {
544
+ "Type": "AWS::IAM::Role",
545
+ "Properties": {
546
+ "AssumeRolePolicyDocument": {
547
+ "Version": "2012-10-17",
548
+ "Statement": [
549
+ {
550
+ "Effect": "Allow",
551
+ "Principal": {"Service": "scheduler.amazonaws.com"},
552
+ "Action": "sts:AssumeRole",
553
+ }
554
+ ],
555
+ },
556
+ "ManagedPolicyArns": [
557
+ {"Ref": policy_ref}
558
+ for policy_ref in send_all_queues_task_ref_policies.keys()
559
+ ],
560
+ "Policies": [
561
+ {
562
+ "PolicyName": "SendToDelayDLQ",
563
+ "PolicyDocument": {
564
+ "Version": "2012-10-17",
565
+ "Statement": [
566
+ {
567
+ "Sid": "SendToDelayDLQ",
568
+ "Effect": "Allow",
569
+ "Action": "sqs:SendMessage",
570
+ "Resource": {
571
+ "Fn::GetAtt": [
572
+ "AsyncLambdaDelayDLQ",
573
+ "Arn",
574
+ ]
575
+ },
576
+ },
577
+ ],
578
+ },
579
+ },
580
+ ],
581
+ },
582
+ }
583
+ task_ref_policies = {
584
+ **send_all_queues_task_ref_policies,
585
+ "AsyncLambdaCreateDelaySchedulesPolicy": template["Resources"][
586
+ "AsyncLambdaCreateDelaySchedulesPolicy"
587
+ ],
588
+ }
476
589
 
477
590
  has_api_tasks = False
478
591
  for task in _task_list:
@@ -768,6 +881,7 @@ class AsyncLambdaController:
768
881
  force_sync: bool = False,
769
882
  lane: Optional[int] = None,
770
883
  message_group_id: Optional[str] = None,
884
+ unique_delay_id: Optional[str] = None,
771
885
  ):
772
886
  """
773
887
  Sends an asynchronous invocation payload to a managed or external task via SQS.
@@ -783,6 +897,8 @@ class AsyncLambdaController:
783
897
  delay (int, optional): Delay in seconds before sending the message. Defaults to 0.
784
898
  force_sync (bool, optional): If True, invokes the task synchronously. Defaults to False.
785
899
  lane (Optional[int], optional): The lane to use for invocation. If None, lane assignment is determined automatically.
900
+ message_group_id (Optional[str], optional): Optional message group ID for enabling SQS Fair Queues. Defaults to None.
901
+ unique_delay_id (Optional[str], optional): Unique name for EventBridge scheduled delays when delay is longer than 900 seconds. Random if not provided.
786
902
 
787
903
  Returns:
788
904
  Any: The result of the synchronous invocation if `force_sync` is True; otherwise, None.
@@ -808,6 +924,7 @@ class AsyncLambdaController:
808
924
  raise Exception(
809
925
  f"No such task exists with the task_id {destination_task_id}"
810
926
  )
927
+
811
928
  destination_task = None
812
929
  if not is_external_task:
813
930
  destination_task = self.tasks[destination_task_id]
@@ -878,16 +995,72 @@ class AsyncLambdaController:
878
995
  assert destination_task is not None
879
996
  url = destination_task.get_managed_queue_url(lane=lane)
880
997
 
881
- _kwargs = {}
882
- if _message_group_id:
883
- _kwargs["MessageGroupId"] = _message_group_id
998
+ if delay <= _SQS_MAX_DELAY_SECONDS:
999
+ _kwargs = {}
1000
+ if _message_group_id:
1001
+ _kwargs["MessageGroupId"] = _message_group_id
884
1002
 
885
- get_sqs_client().send_message(
886
- QueueUrl=url,
887
- MessageBody=json.dumps(sqs_payload),
888
- DelaySeconds=delay,
889
- **_kwargs,
890
- )
1003
+ get_sqs_client().send_message(
1004
+ QueueUrl=url,
1005
+ MessageBody=json.dumps(sqs_payload),
1006
+ DelaySeconds=delay,
1007
+ **_kwargs,
1008
+ )
1009
+ else:
1010
+ self._send_via_scheduler(
1011
+ queue_url=url,
1012
+ message_body=json.dumps(sqs_payload),
1013
+ delay=delay,
1014
+ schedule_name=unique_delay_id,
1015
+ message_group_id=_message_group_id,
1016
+ )
1017
+
1018
+ return None
1019
+
1020
+ @staticmethod
1021
+ def _send_via_scheduler(
1022
+ queue_url: str,
1023
+ message_body: str,
1024
+ delay: int,
1025
+ schedule_name: Optional[str] = None,
1026
+ message_group_id: Optional[str] = None,
1027
+ ):
1028
+ """
1029
+ Schedules a one-time EventBridge scheduler SQS sendMessage at some point in the future.
1030
+
1031
+ Args:
1032
+ schedule_name (str): Unique name for the schedule.
1033
+ queue_url (str): URL of the destination SQS queue.
1034
+ message_body (str): JSON-serialized message body to deliver to the queue.
1035
+ delay (int): Delay in seconds before delivering the message.
1036
+ message_group_id (Optional[str]): MessageGroupId for FIFO queues. Defaults to None.
1037
+ """
1038
+ schedule_time = datetime.now(tz=timezone.utc) + timedelta(seconds=delay)
1039
+ schedule_expression = f"at({schedule_time.strftime('%Y-%m-%dT%H:%M:%S')})"
1040
+
1041
+ sqs_input: dict = {
1042
+ "QueueUrl": queue_url,
1043
+ "MessageBody": message_body,
1044
+ }
1045
+ if message_group_id:
1046
+ sqs_input["MessageGroupId"] = message_group_id
1047
+
1048
+ sqs_universal_target: dict = {
1049
+ "Arn": "arn:aws:scheduler:::aws-sdk:sqs:sendMessage",
1050
+ "RoleArn": env.get_delay_scheduler_role_arn(),
1051
+ "Input": json.dumps(sqs_input),
1052
+ "DeadLetterConfig": {"Arn": env.get_delay_schedule_dlq_arn()},
1053
+ }
1054
+
1055
+ get_scheduler_client().create_schedule(
1056
+ Name=schedule_name or uuid4(),
1057
+ GroupName=env.get_delay_schedule_group_name(),
1058
+ ScheduleExpression=schedule_expression,
1059
+ ScheduleExpressionTimezone="UTC",
1060
+ FlexibleTimeWindow={"Mode": "OFF"},
1061
+ Target=sqs_universal_target,
1062
+ ActionAfterCompletion="DELETE",
1063
+ )
891
1064
 
892
1065
  def send_async_invoke_payload_batch(
893
1066
  self,
@@ -1172,6 +1345,7 @@ class AsyncLambdaController:
1172
1345
  force_sync: bool = False,
1173
1346
  lane: Optional[int] = None,
1174
1347
  message_group_id: Optional[str] = None,
1348
+ unique_delay_id: Optional[str] = None,
1175
1349
  ):
1176
1350
  """
1177
1351
  Asynchronously invokes a task by sending a payload to the specified destination.
@@ -1186,6 +1360,8 @@ class AsyncLambdaController:
1186
1360
  delay (int, optional): Delay in seconds before invoking the task. Defaults to 0.
1187
1361
  force_sync (bool, optional): If True, forces synchronous invocation. Defaults to False.
1188
1362
  lane (Optional[int], optional): Optional lane identifier for routing. Defaults to None.
1363
+ message_group_id (Optional[str], optional): Optional message group ID. Defaults to None.
1364
+ unique_delay_id (Optional[str], optional): Unique name for EventBridge scheduled delays when delay is longer than 900 seconds. Random if not provided.
1189
1365
 
1190
1366
  Returns:
1191
1367
  Any: The result of sending the asynchronous invocation payload.
@@ -1201,6 +1377,7 @@ class AsyncLambdaController:
1201
1377
  force_sync=force_sync,
1202
1378
  lane=lane,
1203
1379
  message_group_id=message_group_id,
1380
+ unique_delay_id=unique_delay_id,
1204
1381
  )
1205
1382
 
1206
1383
  def async_invoke_batch(
@@ -206,3 +206,50 @@ def get_batch_failure_retry_count() -> int:
206
206
  int: The number of times to retry batch failures. Defaults to 20 if the environment variable is not set.
207
207
  """
208
208
  return int(os.environ.get("ASYNC_LAMBDA_BATCH_FAILURE_RETRY_COUNT", 20))
209
+
210
+
211
+ def get_delay_scheduler_role_arn() -> str:
212
+ """
213
+ Retrieves the IAM role ARN used by EventBridge Scheduler to send messages to SQS.
214
+
215
+ The role must have sqs:SendMessage permission on the target queues.
216
+
217
+ Returns:
218
+ str: The value of the 'ASYNC_LAMBDA_DELAY_SCHEDULER_ROLE_ARN' environment variable.
219
+
220
+ Raises:
221
+ KeyError: If 'ASYNC_LAMBDA_DELAY_SCHEDULER_ROLE_ARN' is not set in the environment.
222
+ """
223
+ return os.environ["ASYNC_LAMBDA_DELAY_SCHEDULER_ROLE_ARN"]
224
+
225
+
226
+ def get_delay_schedule_group_name() -> str:
227
+ """
228
+ Retrieves the EventBridge Scheduler delay schedule group name for this stack.
229
+
230
+ One group is provisioned per CloudFormation stack (set via AWS::Scheduler::ScheduleGroup
231
+ in the SAM template) and injected as ASYNC_LAMBDA_DELAY_SCHEDULE_GROUP when there internally managed queues.
232
+
233
+ Returns:
234
+ str: The value of the 'ASYNC_LAMBDA_DELAY_SCHEDULE_GROUP' environment variable.
235
+
236
+ Raises:
237
+ KeyError: If 'ASYNC_LAMBDA_DELAY_SCHEDULE_GROUP' is not set in the environment.
238
+ """
239
+ return os.environ["ASYNC_LAMBDA_DELAY_SCHEDULE_GROUP"]
240
+
241
+
242
+ def get_delay_schedule_dlq_arn() -> str:
243
+ """
244
+ Retrieves the EventBridge Scheduler delay DLQ for this stack.
245
+
246
+ One SQS DLQ is provisioned per CloudFormation stack. EventBridge Scheduler performs a very large number of retries,
247
+ so ending up here would most likely be due to permissions or target deletion.
248
+
249
+ Returns:
250
+ str: The value of the 'ASYNC_LAMBDA_DELAY_SCHEDULE_DLQ_ARN' environment variable.
251
+
252
+ Raises:
253
+ KeyError: If 'ASYNC_LAMBDA_DELAY_SCHEDULE_DLQ_ARN' is not set in the environment.
254
+ """
255
+ return os.environ["ASYNC_LAMBDA_DELAY_SCHEDULE_DLQ_ARN"]