jararaca 0.2.37a11__py3-none-any.whl → 0.3.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.

Potentially problematic release.


This version of jararaca might be problematic. Click here for more details.

@@ -0,0 +1,608 @@
1
+ import asyncio
2
+ import inspect
3
+ import logging
4
+ import signal
5
+ from abc import ABC
6
+ from contextlib import asynccontextmanager, suppress
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from typing import (
10
+ Any,
11
+ AsyncContextManager,
12
+ AsyncGenerator,
13
+ Awaitable,
14
+ Callable,
15
+ Type,
16
+ get_origin,
17
+ )
18
+ from urllib.parse import parse_qs, urlparse
19
+
20
+ import aio_pika
21
+ import aio_pika.abc
22
+ import uvloop
23
+ from pydantic import BaseModel
24
+
25
+ from jararaca.broker_backend import MessageBrokerBackend
26
+ from jararaca.broker_backend.mapper import get_message_broker_backend_from_url
27
+ from jararaca.core.uow import UnitOfWorkContextProvider
28
+ from jararaca.di import Container
29
+ from jararaca.lifecycle import AppLifecycle
30
+ from jararaca.messagebus.bus_message_controller import (
31
+ BusMessageController,
32
+ provide_bus_message_controller,
33
+ )
34
+ from jararaca.messagebus.decorators import (
35
+ MESSAGE_HANDLER_DATA_SET,
36
+ SCHEDULED_ACTION_DATA_SET,
37
+ MessageBusController,
38
+ MessageHandler,
39
+ MessageHandlerData,
40
+ ScheduledActionData,
41
+ ScheduleDispatchData,
42
+ )
43
+ from jararaca.messagebus.message import Message, MessageOf
44
+ from jararaca.microservice import (
45
+ MessageBusAppContext,
46
+ Microservice,
47
+ SchedulerAppContext,
48
+ )
49
+ from jararaca.utils.rabbitmq_utils import RabbitmqUtils
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+
54
+ @dataclass
55
+ class AioPikaWorkerConfig:
56
+ url: str
57
+ exchange: str
58
+ prefetch_count: int
59
+
60
+
61
+ class AioPikaMessage(MessageOf[Message]):
62
+
63
+ def __init__(
64
+ self,
65
+ aio_pika_message: aio_pika.abc.AbstractIncomingMessage,
66
+ model_type: Type[Message],
67
+ ):
68
+ self.aio_pika_message = aio_pika_message
69
+ self.model_type = model_type
70
+
71
+ def payload(self) -> Message:
72
+ return self.model_type.model_validate_json(self.aio_pika_message.body)
73
+
74
+
75
+ class MessageProcessingLocker:
76
+
77
+ def __init__(self) -> None:
78
+ self.messages_lock = asyncio.Lock()
79
+ self.current_processing_messages_set: set[asyncio.Task[Any]] = set()
80
+
81
+ @asynccontextmanager
82
+ async def lock_message_task(
83
+ self, task: asyncio.Task[Any]
84
+ ) -> AsyncGenerator[None, Any]:
85
+ async with self.messages_lock:
86
+ self.current_processing_messages_set.add(task)
87
+ try:
88
+ yield
89
+ finally:
90
+ self.current_processing_messages_set.discard(task)
91
+
92
+ async def wait_all_messages_processed(self) -> None:
93
+ if len(self.current_processing_messages_set) == 0:
94
+ return
95
+
96
+ await asyncio.gather(*self.current_processing_messages_set)
97
+
98
+
99
+ class MessageBusConsumer(ABC):
100
+
101
+ async def consume(self) -> None:
102
+ raise NotImplementedError("consume method not implemented")
103
+
104
+ def shutdown(self) -> None: ...
105
+
106
+
107
+ class AioPikaMicroserviceConsumer(MessageBusConsumer):
108
+ def __init__(
109
+ self,
110
+ broker_backend: MessageBrokerBackend,
111
+ config: AioPikaWorkerConfig,
112
+ message_handler_set: MESSAGE_HANDLER_DATA_SET,
113
+ scheduled_actions: SCHEDULED_ACTION_DATA_SET,
114
+ uow_context_provider: UnitOfWorkContextProvider,
115
+ ):
116
+
117
+ self.broker_backend = broker_backend
118
+ self.config = config
119
+ self.message_handler_set = message_handler_set
120
+ self.scheduled_actions = scheduled_actions
121
+ self.incoming_map: dict[str, MessageHandlerData] = {}
122
+ self.uow_context_provider = uow_context_provider
123
+ self.shutdown_event = asyncio.Event()
124
+ self.lock = asyncio.Lock()
125
+ self.tasks: set[asyncio.Task[Any]] = set()
126
+
127
+ async def consume(self) -> None:
128
+
129
+ connection = await aio_pika.connect(self.config.url)
130
+
131
+ channel = await connection.channel()
132
+
133
+ await channel.set_qos(prefetch_count=self.config.prefetch_count)
134
+
135
+ await RabbitmqUtils.declare_main_exchange(
136
+ channel=channel,
137
+ exchange_name=self.config.exchange,
138
+ )
139
+
140
+ dlx = await RabbitmqUtils.declare_dl_exchange(channel=channel)
141
+
142
+ dlq = await RabbitmqUtils.declare_dl_queue(channel=channel)
143
+
144
+ await dlq.bind(dlx, routing_key=RabbitmqUtils.DEAD_LETTER_EXCHANGE)
145
+
146
+ for handler in self.message_handler_set:
147
+
148
+ queue_name = f"{handler.message_type.MESSAGE_TOPIC}.{handler.callable.__module__}.{handler.callable.__qualname__}"
149
+ routing_key = f"{handler.message_type.MESSAGE_TOPIC}.#"
150
+
151
+ self.incoming_map[queue_name] = handler
152
+
153
+ queue = await RabbitmqUtils.declare_queue(
154
+ channel=channel, queue_name=queue_name
155
+ )
156
+
157
+ await queue.bind(exchange=self.config.exchange, routing_key=routing_key)
158
+
159
+ await queue.consume(
160
+ callback=MessageHandlerCallback(
161
+ consumer=self,
162
+ queue_name=queue_name,
163
+ routing_key=routing_key,
164
+ message_handler=handler,
165
+ ),
166
+ no_ack=handler.spec.auto_ack,
167
+ )
168
+
169
+ logger.info(f"Consuming message handler {queue_name}")
170
+
171
+ for scheduled_action in self.scheduled_actions:
172
+
173
+ queue_name = f"{scheduled_action.callable.__module__}.{scheduled_action.callable.__qualname__}"
174
+
175
+ routing_key = queue_name
176
+
177
+ queue = await channel.declare_queue(queue_name, durable=True)
178
+
179
+ await queue.bind(exchange=self.config.exchange, routing_key=routing_key)
180
+
181
+ await queue.consume(
182
+ callback=ScheduledMessageHandlerCallback(
183
+ consumer=self,
184
+ queue_name=queue_name,
185
+ routing_key=routing_key,
186
+ scheduled_action=scheduled_action,
187
+ ),
188
+ no_ack=True,
189
+ )
190
+
191
+ logger.info(f"Consuming scheduler {queue_name}")
192
+
193
+ await self.shutdown_event.wait()
194
+ logger.info("Worker shutting down")
195
+
196
+ await self.wait_all_tasks_done()
197
+
198
+ await channel.close()
199
+ await connection.close()
200
+
201
+ async def wait_all_tasks_done(self) -> None:
202
+ async with self.lock:
203
+ await asyncio.gather(*self.tasks)
204
+
205
+ def shutdown(self) -> None:
206
+ self.shutdown_event.set()
207
+
208
+
209
+ def create_message_bus(
210
+ broker_url: str,
211
+ broker_backend: MessageBrokerBackend,
212
+ scheduled_actions: SCHEDULED_ACTION_DATA_SET,
213
+ message_handler_set: MESSAGE_HANDLER_DATA_SET,
214
+ uow_context_provider: UnitOfWorkContextProvider,
215
+ ) -> MessageBusConsumer:
216
+
217
+ parsed_url = urlparse(broker_url)
218
+
219
+ if parsed_url.scheme == "amqp" or parsed_url.scheme == "amqps":
220
+ assert parsed_url.query, "Query string must be set for AMQP URLs"
221
+
222
+ query_params: dict[str, list[str]] = parse_qs(parsed_url.query)
223
+
224
+ assert "exchange" in query_params, "Exchange must be set in the query string"
225
+ assert (
226
+ len(query_params["exchange"]) == 1
227
+ ), "Exchange must be set in the query string"
228
+ assert (
229
+ "prefetch_count" in query_params
230
+ ), "Prefetch count must be set in the query string"
231
+ assert (
232
+ len(query_params["prefetch_count"]) == 1
233
+ ), "Prefetch count must be set in the query string"
234
+ assert query_params["prefetch_count"][
235
+ 0
236
+ ].isdigit(), "Prefetch count must be an integer in the query string"
237
+ assert query_params["exchange"][0], "Exchange must be set in the query string"
238
+ assert query_params["prefetch_count"][
239
+ 0
240
+ ], "Prefetch count must be set in the query string"
241
+
242
+ exchange = query_params["exchange"][0]
243
+ prefetch_count = int(query_params["prefetch_count"][0])
244
+
245
+ config = AioPikaWorkerConfig(
246
+ url=broker_url,
247
+ exchange=exchange,
248
+ prefetch_count=prefetch_count,
249
+ )
250
+
251
+ return AioPikaMicroserviceConsumer(
252
+ config=config,
253
+ broker_backend=broker_backend,
254
+ message_handler_set=message_handler_set,
255
+ scheduled_actions=scheduled_actions,
256
+ uow_context_provider=uow_context_provider,
257
+ )
258
+
259
+ raise ValueError(
260
+ f"Unsupported broker URL scheme: {parsed_url.scheme}. Supported schemes are amqp and amqps"
261
+ )
262
+
263
+
264
+ class ScheduledMessageHandlerCallback:
265
+ def __init__(
266
+ self,
267
+ consumer: AioPikaMicroserviceConsumer,
268
+ queue_name: str,
269
+ routing_key: str,
270
+ scheduled_action: ScheduledActionData,
271
+ ):
272
+ self.consumer = consumer
273
+ self.queue_name = queue_name
274
+ self.routing_key = routing_key
275
+ self.scheduled_action = scheduled_action
276
+
277
+ async def __call__(
278
+ self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
279
+ ) -> None:
280
+
281
+ if self.consumer.shutdown_event.is_set():
282
+ return
283
+
284
+ async with self.consumer.lock:
285
+ task = asyncio.create_task(self.handle_message(aio_pika_message))
286
+ self.consumer.tasks.add(task)
287
+ task.add_done_callback(self.handle_message_consume_done)
288
+
289
+ def handle_message_consume_done(self, task: asyncio.Task[Any]) -> None:
290
+ self.consumer.tasks.discard(task)
291
+
292
+ async def handle_message(
293
+ self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
294
+ ) -> None:
295
+
296
+ if self.consumer.shutdown_event.is_set():
297
+ logger.info("Shutdown event set. Rqueuing message")
298
+ await aio_pika_message.reject(requeue=True)
299
+
300
+ sig = inspect.signature(self.scheduled_action.callable)
301
+ if len(sig.parameters) == 1:
302
+
303
+ task = asyncio.create_task(
304
+ self.run_with_context(
305
+ self.scheduled_action.callable,
306
+ self.scheduled_action,
307
+ (ScheduleDispatchData(int(aio_pika_message.body.decode("utf-8"))),),
308
+ {},
309
+ )
310
+ )
311
+
312
+ elif len(sig.parameters) == 0:
313
+ task = asyncio.create_task(
314
+ self.run_with_context(
315
+ self.scheduled_action.callable,
316
+ self.scheduled_action,
317
+ (),
318
+ {},
319
+ )
320
+ )
321
+ else:
322
+ logger.warning(
323
+ "Scheduled action '%s' must have exactly one parameter of type ScheduleDispatchData or no parameters"
324
+ % self.queue_name
325
+ )
326
+ return
327
+
328
+ self.consumer.tasks.add(task)
329
+ task.add_done_callback(self.handle_message_consume_done)
330
+
331
+ try:
332
+ await task
333
+ except Exception as e:
334
+
335
+ logger.exception(
336
+ f"Error processing scheduled action {self.queue_name}: {e}"
337
+ )
338
+
339
+ async def run_with_context(
340
+ self,
341
+ func: Callable[..., Awaitable[None]],
342
+ scheduled_action: ScheduledActionData,
343
+ args: tuple[Any, ...],
344
+ kwargs: dict[str, Any],
345
+ ) -> None:
346
+ async with self.consumer.uow_context_provider(
347
+ SchedulerAppContext(
348
+ action=func,
349
+ scheduled_to=datetime.now(UTC),
350
+ cron_expression=scheduled_action.spec.cron,
351
+ triggered_at=datetime.now(UTC),
352
+ )
353
+ ):
354
+
355
+ await func(*args, **kwargs)
356
+
357
+
358
+ class MessageHandlerCallback:
359
+
360
+ def __init__(
361
+ self,
362
+ consumer: AioPikaMicroserviceConsumer,
363
+ queue_name: str,
364
+ routing_key: str,
365
+ message_handler: MessageHandlerData,
366
+ ):
367
+ self.consumer = consumer
368
+ self.queue_name = queue_name
369
+ self.routing_key = routing_key
370
+ self.message_handler = message_handler
371
+
372
+ async def message_consumer(
373
+ self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
374
+ ) -> None:
375
+ if self.consumer.shutdown_event.is_set():
376
+ return
377
+
378
+ async with self.consumer.lock:
379
+ task = asyncio.create_task(self.handle_message(aio_pika_message))
380
+ self.consumer.tasks.add(task)
381
+ task.add_done_callback(self.handle_message_consume_done)
382
+
383
+ def handle_message_consume_done(self, task: asyncio.Task[Any]) -> None:
384
+ self.consumer.tasks.discard(task)
385
+ if task.cancelled():
386
+ return
387
+
388
+ if (error := task.exception()) is not None:
389
+ logger.exception("Error processing message", exc_info=error)
390
+
391
+ async def __call__(
392
+ self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
393
+ ) -> None:
394
+ await self.message_consumer(aio_pika_message)
395
+
396
+ async def handle_reject_message(
397
+ self,
398
+ aio_pika_message: aio_pika.abc.AbstractIncomingMessage,
399
+ requeue: bool = False,
400
+ ) -> None:
401
+ if self.message_handler.spec.auto_ack is False:
402
+ await aio_pika_message.reject(requeue=requeue)
403
+ elif requeue:
404
+ logger.warning(
405
+ f"Message {aio_pika_message.message_id} ({self.queue_name}) cannot be requeued because auto_ack is enabled"
406
+ )
407
+
408
+ async def handle_message(
409
+ self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
410
+ ) -> None:
411
+
412
+ routing_key = self.queue_name
413
+
414
+ if routing_key is None:
415
+ logger.warning("No topic found for message")
416
+ await self.handle_reject_message(aio_pika_message)
417
+ return
418
+
419
+ handler_data = self.message_handler
420
+
421
+ handler = handler_data.callable
422
+
423
+ sig = inspect.signature(handler)
424
+
425
+ if len(sig.parameters) != 1:
426
+ logger.warning(
427
+ "Handler for topic '%s' must have exactly one parameter which is MessageOf[T extends Message]"
428
+ % routing_key
429
+ )
430
+ return
431
+
432
+ parameter = list(sig.parameters.values())[0]
433
+
434
+ param_origin = get_origin(parameter.annotation)
435
+
436
+ if param_origin is not MessageOf:
437
+ logger.warning(
438
+ "Handler for topic '%s' must have exactly one parameter of type Message"
439
+ % routing_key
440
+ )
441
+ return
442
+
443
+ if len(parameter.annotation.__args__) != 1:
444
+ logger.warning(
445
+ "Handler for topic '%s' must have exactly one parameter of type Message"
446
+ % routing_key
447
+ )
448
+ return
449
+
450
+ message_type = parameter.annotation.__args__[0]
451
+
452
+ if not issubclass(message_type, BaseModel):
453
+ logger.warning(
454
+ "Handler for topic '%s' must have exactly one parameter of type MessageOf[BaseModel]"
455
+ % routing_key
456
+ )
457
+ return
458
+
459
+ builded_message = AioPikaMessage(aio_pika_message, message_type)
460
+
461
+ incoming_message_spec = MessageHandler.get_message_incoming(handler)
462
+ assert incoming_message_spec is not None
463
+
464
+ async with self.consumer.uow_context_provider(
465
+ MessageBusAppContext(
466
+ message=builded_message,
467
+ topic=routing_key,
468
+ )
469
+ ):
470
+ ctx: AsyncContextManager[Any]
471
+ if incoming_message_spec.timeout is not None:
472
+ ctx = asyncio.timeout(incoming_message_spec.timeout)
473
+ else:
474
+ ctx = none_context()
475
+ async with ctx:
476
+ try:
477
+ with provide_bus_message_controller(
478
+ AioPikaMessageBusController(aio_pika_message)
479
+ ):
480
+ await handler(builded_message)
481
+ if not incoming_message_spec.auto_ack:
482
+ with suppress(aio_pika.MessageProcessError):
483
+ await aio_pika_message.ack()
484
+ except BaseException as base_exc:
485
+ if incoming_message_spec.exception_handler is not None:
486
+ try:
487
+ incoming_message_spec.exception_handler(base_exc)
488
+ except Exception as nested_exc:
489
+ logger.exception(
490
+ f"Error processing exception handler: {base_exc} | {nested_exc}"
491
+ )
492
+ else:
493
+ logger.exception(
494
+ f"Error processing message on topic {routing_key}"
495
+ )
496
+ if incoming_message_spec.requeue_on_exception:
497
+ await self.handle_reject_message(aio_pika_message, requeue=True)
498
+ else:
499
+ await self.handle_reject_message(
500
+ aio_pika_message, requeue=False
501
+ )
502
+ else:
503
+ logger.info(
504
+ f"Message {aio_pika_message.message_id}#{self.queue_name} processed successfully"
505
+ )
506
+
507
+
508
+ @asynccontextmanager
509
+ async def none_context() -> AsyncGenerator[None, None]:
510
+ yield
511
+
512
+
513
+ class MessageBusWorker:
514
+ def __init__(self, app: Microservice, broker_url: str, backend_url: str) -> None:
515
+ self.app = app
516
+ self.backend_url = backend_url
517
+ self.broker_url = broker_url
518
+
519
+ self.container = Container(app)
520
+ self.lifecycle = AppLifecycle(app, self.container)
521
+
522
+ self.uow_context_provider = UnitOfWorkContextProvider(
523
+ app=app, container=self.container
524
+ )
525
+
526
+ self._consumer: MessageBusConsumer | None = None
527
+
528
+ @property
529
+ def consumer(self) -> MessageBusConsumer:
530
+ if self._consumer is None:
531
+ raise RuntimeError("Consumer not started")
532
+ return self._consumer
533
+
534
+ async def start_async(self) -> None:
535
+ all_message_handlers_set: MESSAGE_HANDLER_DATA_SET = set()
536
+ all_scheduled_actions_set: SCHEDULED_ACTION_DATA_SET = set()
537
+ async with self.lifecycle():
538
+ for instance_class in self.app.controllers:
539
+ controller = MessageBusController.get_messagebus(instance_class)
540
+
541
+ if controller is None:
542
+ continue
543
+
544
+ instance: Any = self.container.get_by_type(instance_class)
545
+
546
+ factory = controller.get_messagebus_factory()
547
+ handlers, schedulers = factory(instance)
548
+
549
+ message_handler_data_map: dict[str, MessageHandlerData] = {}
550
+ all_scheduled_actions_set.update(schedulers)
551
+ for handler_data in handlers:
552
+ message_type = handler_data.spec.message_type
553
+ topic = message_type.MESSAGE_TOPIC
554
+ if (
555
+ topic in message_handler_data_map
556
+ and message_type.MESSAGE_TYPE == "task"
557
+ ):
558
+ logger.warning(
559
+ "Task handler for topic '%s' already registered. Skipping"
560
+ % topic
561
+ )
562
+ continue
563
+ message_handler_data_map[topic] = handler_data
564
+ all_message_handlers_set.add(handler_data)
565
+
566
+ broker_backend = get_message_broker_backend_from_url(url=self.backend_url)
567
+
568
+ consumer = self._consumer = create_message_bus(
569
+ broker_url=self.broker_url,
570
+ broker_backend=broker_backend,
571
+ scheduled_actions=all_scheduled_actions_set,
572
+ message_handler_set=all_message_handlers_set,
573
+ uow_context_provider=self.uow_context_provider,
574
+ )
575
+
576
+ await consumer.consume()
577
+
578
+ def start_sync(self) -> None:
579
+
580
+ def on_shutdown(loop: asyncio.AbstractEventLoop) -> None:
581
+ logger.info("Shutting down")
582
+ self.consumer.shutdown()
583
+
584
+ with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
585
+ runner.get_loop().add_signal_handler(
586
+ signal.SIGINT, on_shutdown, runner.get_loop()
587
+ )
588
+ runner.run(self.start_async())
589
+
590
+
591
+ class AioPikaMessageBusController(BusMessageController):
592
+ def __init__(self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage):
593
+ self.aio_pika_message = aio_pika_message
594
+
595
+ async def ack(self) -> None:
596
+ await self.aio_pika_message.ack()
597
+
598
+ async def nack(self) -> None:
599
+ await self.aio_pika_message.nack()
600
+
601
+ async def reject(self) -> None:
602
+ await self.aio_pika_message.reject()
603
+
604
+ async def retry(self) -> None:
605
+ await self.aio_pika_message.reject(requeue=True)
606
+
607
+ async def retry_later(self, delay: int) -> None:
608
+ raise NotImplementedError("Not implemented")
jararaca/microservice.py CHANGED
@@ -22,7 +22,7 @@ from fastapi import Request, WebSocket
22
22
 
23
23
  from jararaca.core.providers import ProviderSpec, T, Token
24
24
  from jararaca.messagebus import MessageOf
25
- from jararaca.messagebus.types import Message
25
+ from jararaca.messagebus.message import Message
26
26
 
27
27
  if TYPE_CHECKING:
28
28
  from typing_extensions import TypeIs
@@ -11,7 +11,7 @@ class ScheduledAction:
11
11
  self,
12
12
  cron: str,
13
13
  allow_overlap: bool = False,
14
- exclusive: bool = False,
14
+ exclusive: bool = True,
15
15
  timeout: int | None = None,
16
16
  exception_handler: Callable[[BaseException], None] | None = None,
17
17
  ) -> None:
@@ -19,12 +19,35 @@ class ScheduledAction:
19
19
  :param cron: A string representing the cron expression for the scheduled action.
20
20
  :param allow_overlap: A boolean indicating if the scheduled action should new executions even if the previous one is still running.
21
21
  :param exclusive: A boolean indicating if the scheduled action should be executed in one instance of the application. (Requires a distributed lock provided by a backend)
22
+ :param exception_handler: A callable that will be called when an exception is raised during the execution of the scheduled action.
23
+ :param timeout: An integer representing the timeout for the scheduled action in seconds. If the scheduled action takes longer than this time, it will be terminated.
22
24
  """
23
25
  self.cron = cron
26
+ """
27
+ A string representing the cron expression for the scheduled action.
28
+ """
29
+
24
30
  self.allow_overlap = allow_overlap
31
+ """
32
+ A boolean indicating if the scheduled action should new executions even if the previous one is still running.
33
+ """
34
+
25
35
  self.exclusive = exclusive
36
+ """
37
+ A boolean indicating if the scheduled action should be executed
38
+ in one instance of the application. (Requires a distributed lock provided by a backend)
39
+ """
40
+
26
41
  self.exception_handler = exception_handler
42
+ """
43
+ A callable that will be called when an exception is raised during the execution of the scheduled action.
44
+ """
45
+
27
46
  self.timeout = timeout
47
+ """
48
+ An integer representing the timeout for the scheduled action in seconds.
49
+ If the scheduled action takes longer than this time, it will be terminated.
50
+ """
28
51
 
29
52
  def __call__(self, func: DECORATED_FUNC) -> DECORATED_FUNC:
30
53
  ScheduledAction.register(func, self)
@@ -61,3 +84,13 @@ class ScheduledAction:
61
84
  scheduled_actions.append((member, scheduled_action))
62
85
 
63
86
  return scheduled_actions
87
+
88
+ @staticmethod
89
+ def get_function_id(
90
+ func: Callable[..., Any],
91
+ ) -> str:
92
+ """
93
+ Get the function ID of the scheduled action.
94
+ This is used to identify the scheduled action in the message broker.
95
+ """
96
+ return f"{func.__module__}.{func.__qualname__}"