async-lambda-unstable 0.6.6__tar.gz → 0.6.7__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.7}/PKG-INFO +1 -1
  2. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/__init__.py +1 -1
  3. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/client.py +19 -1
  4. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/controller.py +270 -97
  5. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/env.py +31 -0
  6. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/.gitignore +0 -0
  7. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/README.md +0 -0
  8. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/build_config.py +0 -0
  9. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/cli.py +0 -0
  10. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/config.py +0 -0
  11. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/defer.py +0 -0
  12. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/middleware.py +0 -0
  13. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/models/__init__.py +0 -0
  14. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/models/api_response.py +0 -0
  15. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/models/case_insensitive_dict.py +0 -0
  16. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/models/events/__init__.py +0 -0
  17. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/models/events/api_event.py +0 -0
  18. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/models/events/base_event.py +0 -0
  19. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/models/events/dynamodb_event.py +0 -0
  20. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/models/events/managed_sqs_batch_event.py +0 -0
  21. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/models/events/managed_sqs_event.py +0 -0
  22. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/models/events/scheduled_event.py +0 -0
  23. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/models/events/unmanaged_sqs_event.py +0 -0
  24. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/models/mock/mock_context.py +0 -0
  25. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/models/mock/mock_event.py +0 -0
  26. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/models/task.py +0 -0
  27. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/payload_encoder.py +0 -0
  28. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/py.typed +0 -0
  29. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/async_lambda/util.py +0 -0
  30. {async_lambda_unstable-0.6.6 → async_lambda_unstable-0.6.7}/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.7
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.7"
@@ -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)
@@ -67,6 +69,30 @@ class BatchInvokeException(Exception):
67
69
  super().__init__(msg)
68
70
 
69
71
 
72
+ # Exception raised when async_invoke_* is called to a non-internal task with a delay greater than _SQS_MAX_DELAY_SECONDS
73
+ class AsyncInvokeInvalidDelay(Exception):
74
+ pass
75
+
76
+
77
+ class _BatchEntry:
78
+ enqueue_id: str
79
+ payload: dict
80
+ delay: int
81
+ message_group_id: Optional[str]
82
+
83
+ def __init__(
84
+ self,
85
+ enqueue_id: str,
86
+ payload: dict,
87
+ delay: int,
88
+ message_group_id: Optional[str],
89
+ ):
90
+ self.enqueue_id = enqueue_id
91
+ self.payload = payload
92
+ self.delay = delay
93
+ self.message_group_id = message_group_id
94
+
95
+
70
96
  class AsyncLambdaController:
71
97
  """
72
98
  AsyncLambdaController manages async tasks, middleware, and invocation logic for the async-lambda framework.
@@ -454,18 +480,20 @@ class AsyncLambdaController:
454
480
  },
455
481
  }
456
482
  _task_list = list(self.tasks.values())
457
- managed_tasks_resources = [
483
+ internal_tasks_resources = [
458
484
  resource
459
485
  for task in _task_list
460
486
  if task.trigger_type in MANAGED_SQS_TASK_TYPES
461
487
  for resource in task.get_policy_sqs_resources()
462
- ] + [
488
+ ]
489
+ external_tasks_resources = [
463
490
  resource
464
491
  for external_async_task_id in self.external_async_tasks
465
492
  for resource in AsyncLambdaTask.get_policy_external_task_resources(
466
493
  external_async_task_id
467
494
  )
468
495
  ]
496
+ managed_tasks_resources = internal_tasks_resources + external_tasks_resources
469
497
  task_ref_policies = {}
470
498
  if len(managed_tasks_resources) > 0:
471
499
  task_ref_policies = self._build_send_to_all_async_lambda_queues_policies(
@@ -474,6 +502,78 @@ class AsyncLambdaController:
474
502
  for key in task_ref_policies.keys():
475
503
  template["Resources"][key] = task_ref_policies[key]
476
504
 
505
+ if len(internal_tasks_resources) > 0:
506
+ template["Resources"]["AsyncLambdaDelayScheduleGroup"] = {
507
+ "Type": "AWS::Scheduler::ScheduleGroup",
508
+ "Properties": {
509
+ "Name": f"{config.name}-delay",
510
+ "Tags": make_cf_tags(build_config.tags),
511
+ },
512
+ }
513
+ template["Resources"]["AsyncLambdaDelaySchedulerRole"] = {
514
+ "Type": "AWS::IAM::Role",
515
+ "Properties": {
516
+ "AssumeRolePolicyDocument": {
517
+ "Version": "2012-10-17",
518
+ "Statement": [
519
+ {
520
+ "Effect": "Allow",
521
+ "Principal": {"Service": "scheduler.amazonaws.com"},
522
+ "Action": "sts:AssumeRole",
523
+ }
524
+ ],
525
+ },
526
+ "ManagedPolicyArns": [
527
+ {"Ref": policy_id} for policy_id in task_ref_policies.keys()
528
+ ],
529
+ },
530
+ }
531
+ template["Resources"][
532
+ "AsyncLambdaCreateDelaySchedulesPolicy"
533
+ ] = task_ref_policies["AsyncLambdaCreateDelaySchedulesPolicy"] = {
534
+ "Type": "AWS::IAM::ManagedPolicy",
535
+ "Properties": {
536
+ "ManagedPolicyName": {
537
+ "Fn::Sub": "${AWS::StackName}-create-delay-schedules"
538
+ },
539
+ "PolicyDocument": {
540
+ "Version": "2012-10-17",
541
+ "Statement": [
542
+ {
543
+ "Sid": "CreateSchedules",
544
+ "Effect": "Allow",
545
+ "Action": "scheduler:CreateSchedule",
546
+ "Resource": {
547
+ "Fn::Sub": "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AsyncLambdaDelayScheduleGroup}/*"
548
+ },
549
+ },
550
+ {
551
+ "Sid": "PassSchedulerExecutionRole",
552
+ "Effect": "Allow",
553
+ "Action": "iam:PassRole",
554
+ "Resource": {
555
+ "Fn::GetAtt": [
556
+ "AsyncLambdaDelaySchedulerRole",
557
+ "Arn",
558
+ ]
559
+ },
560
+ "Condition": {
561
+ "StringEquals": {
562
+ "iam:PassedToService": "scheduler.amazonaws.com"
563
+ }
564
+ },
565
+ },
566
+ ],
567
+ },
568
+ },
569
+ }
570
+ template["Globals"]["Function"]["Environment"]["Variables"][
571
+ "ASYNC_LAMBDA_DELAY_SCHEDULE_GROUP"
572
+ ] = {"Ref": "AsyncLambdaDelayScheduleGroup"}
573
+ template["Globals"]["Function"]["Environment"]["Variables"][
574
+ "ASYNC_LAMBDA_DELAY_SCHEDULER_ROLE_ARN"
575
+ ] = {"Fn::GetAtt": ["AsyncLambdaDelaySchedulerRole", "Arn"]}
576
+
477
577
  has_api_tasks = False
478
578
  for task in _task_list:
479
579
  if task.trigger_type == TaskTriggerType.API_EVENT:
@@ -808,6 +908,12 @@ class AsyncLambdaController:
808
908
  raise Exception(
809
909
  f"No such task exists with the task_id {destination_task_id}"
810
910
  )
911
+
912
+ if is_external_task and delay > _SQS_MAX_DELAY_SECONDS:
913
+ raise AsyncInvokeInvalidDelay(
914
+ f"Unable to invoke task {destination_task_id} with delay {delay} seconds. External tasks only support delays up to {_SQS_MAX_DELAY_SECONDS} seconds."
915
+ )
916
+
811
917
  destination_task = None
812
918
  if not is_external_task:
813
919
  destination_task = self.tasks[destination_task_id]
@@ -878,16 +984,29 @@ class AsyncLambdaController:
878
984
  assert destination_task is not None
879
985
  url = destination_task.get_managed_queue_url(lane=lane)
880
986
 
881
- _kwargs = {}
882
- if _message_group_id:
883
- _kwargs["MessageGroupId"] = _message_group_id
987
+ if delay > _SQS_MAX_DELAY_SECONDS:
988
+ assert not is_external_task
884
989
 
885
- get_sqs_client().send_message(
886
- QueueUrl=url,
887
- MessageBody=json.dumps(sqs_payload),
888
- DelaySeconds=delay,
889
- **_kwargs,
890
- )
990
+ queue_arn = destination_task.get_managed_queue_arn(lane=lane)
991
+ # TODO: Use _message_group_id when AWS tells me how
992
+ self._send_via_scheduler(
993
+ queue_arn=queue_arn,
994
+ message_body=json.dumps(sqs_payload),
995
+ delay=delay,
996
+ message_group_id=_message_group_id,
997
+ )
998
+ else:
999
+ _kwargs = {}
1000
+ if _message_group_id:
1001
+ _kwargs["MessageGroupId"] = _message_group_id
1002
+
1003
+ get_sqs_client().send_message(
1004
+ QueueUrl=url,
1005
+ MessageBody=json.dumps(sqs_payload),
1006
+ DelaySeconds=delay,
1007
+ **_kwargs,
1008
+ )
1009
+ return None
891
1010
 
892
1011
  def send_async_invoke_payload_batch(
893
1012
  self,
@@ -964,6 +1083,35 @@ class AsyncLambdaController:
964
1083
  )
965
1084
  lane = 0
966
1085
 
1086
+ batch_entries: List[_BatchEntry] = []
1087
+ for i, sqs_payload in enumerate(sqs_payloads):
1088
+ if isinstance(delay, Sequence):
1089
+ _delay = delay[i]
1090
+ else:
1091
+ _delay = delay
1092
+
1093
+ _message_group_id = (
1094
+ message_group_id[i]
1095
+ if isinstance(message_group_id, Sequence)
1096
+ and not isinstance(message_group_id, str)
1097
+ else message_group_id
1098
+ )
1099
+ if (
1100
+ _message_group_id is None
1101
+ and (current_message_group_id := self.get_current_message_group_id())
1102
+ is not None
1103
+ and self.should_propagate_message_group_id()
1104
+ ):
1105
+ _message_group_id = current_message_group_id
1106
+ batch_entries.append(
1107
+ _BatchEntry(
1108
+ enqueue_id=f"index_{index + i}",
1109
+ payload=sqs_payload,
1110
+ delay=_delay,
1111
+ message_group_id=_message_group_id,
1112
+ )
1113
+ )
1114
+
967
1115
  if force_sync or env.get_force_sync_mode():
968
1116
  if is_external_task:
969
1117
  raise NotImplementedError(
@@ -974,34 +1122,16 @@ class AsyncLambdaController:
974
1122
  current_lane = self.get_current_lane()
975
1123
  assert destination_task is not None
976
1124
  queue_arn = destination_task.get_managed_queue_arn(lane=lane)
977
- for i, sqs_payload in enumerate(sqs_payloads):
978
- if delay:
979
- if isinstance(delay, Sequence):
980
- time.sleep(delay[i])
981
- else:
982
- time.sleep(delay)
983
-
984
- _message_group_id = (
985
- message_group_id[i]
986
- if isinstance(message_group_id, Sequence)
987
- and not isinstance(message_group_id, str)
988
- else message_group_id
989
- )
990
- if (
991
- _message_group_id is None
992
- and (
993
- current_message_group_id := self.get_current_message_group_id()
994
- )
995
- is not None
996
- and self.should_propagate_message_group_id()
997
- ):
998
- _message_group_id = current_message_group_id
1125
+
1126
+ for batch in batch_entries:
1127
+ if batch.delay:
1128
+ time.sleep(batch.delay)
999
1129
 
1000
1130
  current_message_group_id = self.get_current_message_group_id()
1001
1131
  mock_event = MockSQSLambdaEvent(
1002
- json.dumps(sqs_payload),
1132
+ json.dumps(batch.payload),
1003
1133
  source_queue_arn=queue_arn,
1004
- message_group_id=_message_group_id,
1134
+ message_group_id=batch.message_group_id,
1005
1135
  )
1006
1136
  mock_context = MockLambdaContext(destination_task.task_id)
1007
1137
  self.handle_invocation(
@@ -1011,69 +1141,112 @@ class AsyncLambdaController:
1011
1141
  self.set_current_task_id(current_task_id)
1012
1142
  self.set_current_message_group_id(current_message_group_id)
1013
1143
  else:
1014
- entries: List[dict] = []
1015
- for i, sqs_payload in enumerate(sqs_payloads):
1016
- if isinstance(delay, Sequence):
1017
- _delay = delay[i]
1144
+ sqs_entries: List[dict] = []
1145
+ for batch in batch_entries:
1146
+ if batch.delay > _SQS_MAX_DELAY_SECONDS:
1147
+ if is_external_task:
1148
+ raise AsyncInvokeInvalidDelay(
1149
+ f"send_async_invoke_payload_batch does not support a delay longer than {_SQS_MAX_DELAY_SECONDS} seconds for external tasks"
1150
+ )
1151
+ assert destination_task is not None
1152
+ queue_arn = destination_task.get_managed_queue_arn(lane=lane)
1153
+ self._send_via_scheduler(
1154
+ queue_arn=queue_arn,
1155
+ message_body=json.dumps(batch.payload),
1156
+ delay=batch.delay,
1157
+ )
1018
1158
  else:
1019
- _delay = delay
1020
-
1021
- _message_group_id = (
1022
- message_group_id[i]
1023
- if isinstance(message_group_id, Sequence)
1024
- and not isinstance(message_group_id, str)
1025
- else message_group_id
1026
- )
1027
- if (
1028
- _message_group_id is None
1029
- and (
1030
- current_message_group_id := self.get_current_message_group_id()
1159
+ entry: dict = {
1160
+ "MessageBody": json.dumps(batch.payload),
1161
+ "DelaySeconds": batch.delay,
1162
+ "Id": batch.enqueue_id,
1163
+ }
1164
+ if batch.message_group_id:
1165
+ entry["MessageGroupId"] = batch.message_group_id
1166
+ sqs_entries.append(entry)
1167
+ if sqs_entries:
1168
+ if is_external_task:
1169
+ url = f"https://sqs.{env.get_aws_region()}.amazonaws.com/{env.get_aws_account_id()}/{destination_task_id}"
1170
+ else:
1171
+ assert destination_task is not None
1172
+ url = destination_task.get_managed_queue_url(lane=lane)
1173
+ failed_messages: List[dict] = []
1174
+ batch_retry_count = env.get_batch_failure_retry_count() + 1
1175
+ entries = sqs_entries
1176
+ for i in range(batch_retry_count):
1177
+ response = get_sqs_client().send_message_batch(
1178
+ QueueUrl=url,
1179
+ Entries=entries,
1031
1180
  )
1032
- is not None
1033
- and self.should_propagate_message_group_id()
1034
- ):
1035
- _message_group_id = current_message_group_id
1036
-
1037
- entry = {
1038
- "MessageBody": json.dumps(sqs_payload),
1039
- "DelaySeconds": _delay,
1040
- "Id": f"index_{index + i}",
1041
- }
1042
- if _message_group_id:
1043
- entry["MessageGroupId"] = _message_group_id
1044
- entries.append(entry)
1045
- if is_external_task:
1046
- url = f"https://sqs.{env.get_aws_region()}.amazonaws.com/{env.get_aws_account_id()}/{destination_task_id}"
1047
- else:
1048
- assert destination_task is not None
1049
- url = destination_task.get_managed_queue_url(lane=lane)
1050
- failed_messages = []
1051
- batch_retry_count = env.get_batch_failure_retry_count() + 1
1052
- for i in range(batch_retry_count):
1053
- response = get_sqs_client().send_message_batch(
1054
- QueueUrl=url,
1055
- Entries=entries,
1181
+ failed_messages = response.get("Failed", [])
1182
+ if len(failed_messages) == 0:
1183
+ return
1184
+ logger.warning(failed_messages)
1185
+ logger.warning(f"{len(failed_messages)} messages failed to send. ")
1186
+ failed_message_ids = {message["Id"] for message in failed_messages}
1187
+ entries = [
1188
+ entry for entry in entries if entry["Id"] in failed_message_ids
1189
+ ]
1190
+ if i < batch_retry_count:
1191
+ send_delay = 0.5 + random.random()
1192
+ logger.info(
1193
+ f"Waiting {send_delay:.3f} before attempting batch failures again."
1194
+ )
1195
+ time.sleep(send_delay)
1196
+ logger.error(failed_messages)
1197
+ raise BatchInvokeException(
1198
+ f"Failed to send {len(failed_messages)} messages.",
1199
+ failed_payloads=[
1200
+ int(entry["Id"].split("_")[-1]) for entry in entries
1201
+ ],
1056
1202
  )
1057
- failed_messages: List[dict] = response.get("Failed", [])
1058
- if len(failed_messages) == 0:
1059
- return
1060
- logger.warning(failed_messages)
1061
- logger.warning(f"{len(failed_messages)} messages failed to send. ")
1062
- failed_message_ids = {message["Id"] for message in failed_messages}
1063
- entries = [
1064
- entry for entry in entries if entry["Id"] in failed_message_ids
1065
- ]
1066
- if i < batch_retry_count:
1067
- send_delay = 0.5 + random.random()
1068
- logger.info(
1069
- f"Waiting {send_delay:.3f} before attempting batch failures again."
1070
- )
1071
- time.sleep(send_delay)
1072
- logger.error(failed_messages)
1073
- raise BatchInvokeException(
1074
- f"Failed to send {len(failed_messages)} messages.",
1075
- failed_payloads=[int(entry["Id"].split("_")[-1]) for entry in entries],
1076
- )
1203
+
1204
+ @staticmethod
1205
+ def _send_via_scheduler(
1206
+ queue_arn: str,
1207
+ message_body: str,
1208
+ delay: int,
1209
+ message_group_id: Optional[str] = None,
1210
+ ):
1211
+ """
1212
+ Schedules a message for future delivery to an SQS queue via EventBridge Scheduler.
1213
+
1214
+ Used when the requested delay exceeds SQS's maximum of 900 seconds (15 minutes).
1215
+ Creates a one-time schedule in the stack-level schedule group (ASYNC_LAMBDA_DELAY_SCHEDULE_GROUP),
1216
+ which is provisioned by CloudFormation. The schedule auto-deletes after firing.
1217
+
1218
+ Requires the following environment variables:
1219
+ - ASYNC_LAMBDA_DELAY_SCHEDULE_GROUP: name of the CloudFormation-managed schedule group.
1220
+ - ASYNC_LAMBDA_DELAY_SCHEDULER_ROLE_ARN: ARN of the IAM role EventBridge Scheduler uses
1221
+ to deliver to the target SQS queue (must have sqs:SendMessage on all queues in the group).
1222
+
1223
+ Args:
1224
+ queue_arn (str): ARN of the destination SQS queue.
1225
+ message_body (str): JSON-serialized message body to deliver to the queue.
1226
+ delay (int): Delay in seconds before delivering the message.
1227
+ message_group_id (Optional[str]): MessageGroupId for FIFO queues. Defaults to None.
1228
+ """
1229
+ schedule_time = datetime.now(tz=timezone.utc) + timedelta(seconds=delay)
1230
+ schedule_expression = f"at({schedule_time.strftime('%Y-%m-%dT%H:%M:%S')})"
1231
+
1232
+ target: dict = {
1233
+ "Arn": queue_arn,
1234
+ "RoleArn": env.get_delay_scheduler_role_arn(),
1235
+ "Input": message_body,
1236
+ }
1237
+ # TODO: Set message_group_id based on feedback from AWS
1238
+ # if message_group_id:
1239
+ # target["SqsParameters"] = {"MessageGroupId": message_group_id}
1240
+
1241
+ get_scheduler_client().create_schedule(
1242
+ Name=uuid4().hex,
1243
+ GroupName=env.get_delay_schedule_group_name(),
1244
+ ScheduleExpression=schedule_expression,
1245
+ ScheduleExpressionTimezone="UTC",
1246
+ FlexibleTimeWindow={"Mode": "OFF"},
1247
+ Target=target,
1248
+ ActionAfterCompletion="DELETE",
1249
+ )
1077
1250
 
1078
1251
  def new_payload(
1079
1252
  self,
@@ -206,3 +206,34 @@ 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"]