celery-misc 1.0.2__tar.gz → 1.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {celery_misc-1.0.2 → celery_misc-1.0.4}/PKG-INFO +1 -1
  2. celery_misc-1.0.4/celery_misc/transactional_outbox/migrations/0002_outboxmessage_meta_data_outboxmessage_strategy.py +23 -0
  3. celery_misc-1.0.4/celery_misc/transactional_outbox/migrations/0003_alter_outboxmessage_idempotency_key.py +18 -0
  4. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/models.py +7 -2
  5. celery_misc-1.0.4/celery_misc/transactional_outbox/outbox_utils.py +125 -0
  6. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/strategies.py +20 -9
  7. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/tasks.py +22 -7
  8. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/tests/test_utils.py +141 -22
  9. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc.egg-info/PKG-INFO +1 -1
  10. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc.egg-info/SOURCES.txt +2 -0
  11. {celery_misc-1.0.2 → celery_misc-1.0.4}/setup.py +1 -1
  12. celery_misc-1.0.2/celery_misc/transactional_outbox/outbox_utils.py +0 -68
  13. {celery_misc-1.0.2 → celery_misc-1.0.4}/LICENSE +0 -0
  14. {celery_misc-1.0.2 → celery_misc-1.0.4}/README.md +0 -0
  15. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/__init__.py +0 -0
  16. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/__init__.py +0 -0
  17. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/admin.py +0 -0
  18. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/apps.py +0 -0
  19. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/enums.py +0 -0
  20. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/migrations/0001_initial.py +0 -0
  21. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/migrations/__init__.py +0 -0
  22. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/models.py +0 -0
  23. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/monitoring_utils.py +0 -0
  24. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/query_sets.py +0 -0
  25. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/signals.py +0 -0
  26. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/task_signals.py +0 -0
  27. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/tasks.py +0 -0
  28. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/tests/__init__.py +0 -0
  29. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/tests/test_tasks.py +0 -0
  30. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/tests/test_utils.py +0 -0
  31. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_utils.py +0 -0
  32. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/django_utils.py +0 -0
  33. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/errors.py +0 -0
  34. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/model_utils.py +0 -0
  35. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/signal_utils.py +0 -0
  36. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/__init__.py +0 -0
  37. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/admin.py +0 -0
  38. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/apps.py +0 -0
  39. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/enums.py +0 -0
  40. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/migrations/0001_initial.py +0 -0
  41. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/migrations/__init__.py +0 -0
  42. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/settings.py +0 -0
  43. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/tests/__init__.py +0 -0
  44. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/utils.py +0 -0
  45. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc.egg-info/dependency_links.txt +0 -0
  46. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc.egg-info/requires.txt +0 -0
  47. {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc.egg-info/top_level.txt +0 -0
  48. {celery_misc-1.0.2 → celery_misc-1.0.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: celery-misc
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: Python async tools for Celery
5
5
  Author: Anton Gorinenko
6
6
  Author-email: anton.gorinenko@gmail.com
@@ -0,0 +1,23 @@
1
+ # Generated by Django 6.0.3 on 2026-03-18 11:31
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('transactional_outbox', '0001_initial'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='outboxmessage',
15
+ name='meta_data',
16
+ field=models.JSONField(blank=True, null=True, verbose_name='Метаданные события'),
17
+ ),
18
+ migrations.AddField(
19
+ model_name='outboxmessage',
20
+ name='strategy',
21
+ field=models.CharField(blank=True, help_text='Например, "celery_misc.transactional_outbox.strategies.DummyCheckStatusStrategy".', max_length=128, null=True, verbose_name='FQCN класса, реализующего стратегию отправки'),
22
+ ),
23
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.3 on 2026-03-18 15:18
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('transactional_outbox', '0002_outboxmessage_meta_data_outboxmessage_strategy'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name='outboxmessage',
15
+ name='idempotency_key',
16
+ field=models.CharField(max_length=128, verbose_name='Idempotency key'),
17
+ ),
18
+ ]
@@ -6,15 +6,20 @@ from celery_misc.transactional_outbox import enums
6
6
  class OutboxMessage(models.Model):
7
7
  """ OUTBOX """
8
8
 
9
- idempotency_key = models.CharField(null=False, blank=False, max_length=64, unique=True, db_index=True,
10
- verbose_name='Idempotency key')
9
+ idempotency_key = models.CharField(null=False, blank=False, max_length=128, verbose_name='Idempotency key')
11
10
  aggregate_type = models.CharField(null=False, blank=False, max_length=128, verbose_name='Тип агрегата',
12
11
  help_text='Например, название модели данных "Order".')
13
12
  aggregate_id = models.CharField(null=False, blank=False, max_length=64, verbose_name='ID агрегата',
14
13
  help_text='Например, ID экземпляра модели данных "164" или "8470df44-5e62-44db-8881-bfa9606b507c".')
15
14
  event_type = models.CharField(null=False, blank=False, max_length=128, verbose_name='Тип события',
16
15
  help_text='Например, "order.created".')
16
+ strategy = models.CharField(
17
+ null=True, blank=True, max_length=128,
18
+ verbose_name='FQCN класса, реализующего стратегию отправки',
19
+ help_text='Например, "celery_misc.transactional_outbox.strategies.DummyCheckStatusStrategy".'
20
+ )
17
21
  payload = models.JSONField(null=False, blank=False, verbose_name='Данные события')
22
+ meta_data = models.JSONField(null=True, blank=True, verbose_name='Метаданные события')
18
23
  status = models.CharField(max_length=50, null=False, blank=False,
19
24
  choices=enums.EventStatuses.choices,
20
25
  default=enums.EventStatuses.PENDING,
@@ -0,0 +1,125 @@
1
+ from typing import TypeVar
2
+
3
+ import uuid6
4
+ from django.db import transaction
5
+ from django.db.models import Model
6
+
7
+ from celery_misc.transactional_outbox import models, tasks, enums
8
+
9
+ T = TypeVar('T', bound=Model)
10
+
11
+
12
+ def resend_events(event_ids: list[int]):
13
+ """ Повторная отправка сообщений """
14
+ if not event_ids:
15
+ raise ValueError('Event_ids is none or empty.')
16
+
17
+ models.OutboxMessage.objects.filter(id__in=event_ids).update(status=enums.EventStatuses.PENDING, sent_at=None)
18
+ tasks.send_events_only_ones.apply_async(kwargs={'event_ids': event_ids})
19
+
20
+
21
+ class OutboxEvent:
22
+ def __init__(self, event_type: str, strategy: str | None = None):
23
+ """ Создание события для отправки """
24
+ self.event_type = event_type
25
+ self.payload = None
26
+ self.idempotency_key = None
27
+ self.instance = None
28
+ self.event_ids = None
29
+ self.strategy = strategy
30
+ self.meta_data = None
31
+
32
+ self._transaction = None
33
+
34
+ def __enter__(self):
35
+ self._transaction = transaction.atomic()
36
+ self._transaction.__enter__()
37
+ self._is_in_transaction = True
38
+ return self
39
+
40
+ def __exit__(self, exc_type, exc_val, exc_tb):
41
+ try:
42
+ if exc_type is None:
43
+ if not self.instance or not self.payload:
44
+ raise ValueError(
45
+ 'Необходимо вызвать функцию send(instance, payload) внутри контекстного менеджера.')
46
+ self._create_outbox_message()
47
+
48
+ if self._transaction:
49
+ self._transaction.__exit__(exc_type, exc_val, exc_tb)
50
+ except Exception as e:
51
+ if self._transaction:
52
+ self._transaction.__exit__(type(e), e, e.__traceback__)
53
+ raise
54
+ finally:
55
+ self._transaction = None
56
+
57
+ def send(self, instance: T,
58
+ payload: dict | list[dict],
59
+ idempotency_key: str | list[str] | None = None,
60
+ meta_data: dict | list[dict] | None = None):
61
+ if self._transaction is None:
62
+ raise RuntimeError(
63
+ 'Метод send должен вызываться внутри контекстного менеджера OutboxEvent.'
64
+ )
65
+
66
+ self.instance = instance
67
+ if not payload:
68
+ raise ValueError('Параметр payload пуст или не указан.')
69
+
70
+ if idempotency_key is None:
71
+ if not isinstance(payload, list):
72
+ self.idempotency_key = [str(uuid6.uuid7())]
73
+ else:
74
+ self.idempotency_key = [str(uuid6.uuid7()) for i in range(len(payload))]
75
+ elif not isinstance(idempotency_key, list):
76
+ self.idempotency_key = [idempotency_key]
77
+ else:
78
+ self.idempotency_key = idempotency_key
79
+
80
+ meta_data_len = 0
81
+ if meta_data:
82
+ if not isinstance(meta_data, list):
83
+ self.meta_data = [meta_data]
84
+ else:
85
+ self.meta_data = meta_data
86
+
87
+ meta_data_len = len(self.meta_data)
88
+
89
+ if not isinstance(payload, list):
90
+ self.payload = [self._serialize_event(payload)]
91
+ else:
92
+ self.payload = [self._serialize_event(i) for i in payload]
93
+
94
+ if len(self.payload) != len(self.idempotency_key):
95
+ raise ValueError('Размерности параметров idempotency_key и payload не совпадают.')
96
+
97
+ i = 0
98
+ for event_payload, idempotency_key in zip(self.payload, self.idempotency_key, strict=True):
99
+ event_payload['idempotency_key'] = idempotency_key
100
+ if i < meta_data_len:
101
+ event_payload['meta_data'] = self.meta_data[i]
102
+ i += 1
103
+
104
+ def _create_outbox_message(self):
105
+ if len(self.payload) == 1:
106
+ message = models.OutboxMessage.objects.create(**self.payload[0])
107
+ self.event_ids = [message.id]
108
+ else:
109
+ objs = [models.OutboxMessage(**payload) for payload in self.payload]
110
+ db_objs = models.OutboxMessage.objects.bulk_create(objs)
111
+ self.event_ids = [msg.id for msg in db_objs]
112
+
113
+ if self.event_ids:
114
+ tasks.send_events_only_ones.apply_async(kwargs={'event_ids': self.event_ids})
115
+
116
+ def _serialize_event(self, event_payload: dict) -> dict:
117
+ aggregate_type = str(type(self.instance).__name__)
118
+ aggregate_id = self.instance.id
119
+ return {
120
+ 'aggregate_type': aggregate_type,
121
+ 'aggregate_id': aggregate_id,
122
+ 'event_type': self.event_type,
123
+ 'payload': event_payload,
124
+ 'strategy': self.strategy
125
+ }
@@ -10,6 +10,18 @@ from celery_misc.transactional_outbox import models, enums
10
10
  logger = logging.getLogger(__name__)
11
11
 
12
12
 
13
+ def get_pending_outbox_messages(event_ids: list[int] = None):
14
+ """ Получение QuerySet сообщений, ожидающих отправку """
15
+ pending_query_set = models.OutboxMessage.objects.filter(
16
+ ~Q(Q(status=enums.EventStatuses.SENT) | Q(status=enums.EventStatuses.EXPIRED)) # Не отправлено или устарело
17
+ )
18
+
19
+ if event_ids:
20
+ pending_query_set = pending_query_set.filter(id__in=event_ids)
21
+
22
+ return pending_query_set
23
+
24
+
13
25
  class BaseSendEventsStrategy(abc.ABC):
14
26
  def __init__(self, batch_size: int | None = None):
15
27
  self.batch_size = batch_size
@@ -24,7 +36,7 @@ class BaseSendEventsStrategy(abc.ABC):
24
36
  """ Дополнительная обработка запроса """
25
37
  return query_set
26
38
 
27
- def send_events(self, event_ids: list[int] = None) -> list[int]:
39
+ def send_events(self, event_ids: list[int] = None, external_filter: Q | None = None) -> list[int]:
28
40
  """
29
41
  Отправка уведомлений во внешнюю систему, например в kafka или rabbit
30
42
  :param event_ids: список ID событий для обработки
@@ -45,13 +57,12 @@ class BaseSendEventsStrategy(abc.ABC):
45
57
  Каждый воркер берет небольшую порцию и быстро освобождает блокировки
46
58
  Другие воркеры могут сразу начать обрабатывать следующие порции
47
59
  """
48
- pending_query_set = models.OutboxMessage.objects.filter(
49
- ~Q(Q(status=enums.EventStatuses.SENT) | Q(status=enums.EventStatuses.EXPIRED)) # Не отправлено или устарело
50
- ).order_by('created_at', 'id')
60
+ pending_query_set = get_pending_outbox_messages(event_ids=event_ids).order_by('created_at', 'id')
61
+
62
+ if external_filter:
63
+ pending_query_set = pending_query_set.filter(external_filter)
51
64
 
52
- if event_ids:
53
- pending_query_set = pending_query_set.filter(id__in=event_ids)
54
- elif self.batch_size:
65
+ if self.batch_size and not event_ids:
55
66
  pending_query_set = pending_query_set[:self.batch_size]
56
67
 
57
68
  pending_query_set = self.process_base_query(pending_query_set)
@@ -88,9 +99,9 @@ class BlockBatchStrategy(BaseSendEventsStrategy, abc.ABC):
88
99
  так как создается одна длительная транзакция. Batch_size желательно уменьшить, чтобы сократить время общей блокировки.
89
100
  """
90
101
 
91
- def send_events(self, event_ids: list[int] = None) -> list[int]:
102
+ def send_events(self, *args, **kwargs) -> list[int]:
92
103
  with transaction.atomic():
93
- return super().send_events(event_ids=event_ids)
104
+ return super().send_events(*args, **kwargs)
94
105
 
95
106
  def process_base_query(self, query_set: QuerySet[models.OutboxMessage]):
96
107
  """ Блокировка запроса
@@ -6,7 +6,7 @@ from django.db.models import Q
6
6
  from django.utils import timezone
7
7
 
8
8
  from celery_misc import celery_utils, utils, model_utils
9
- from celery_misc.transactional_outbox import settings, enums, models
9
+ from celery_misc.transactional_outbox import settings, enums, models, strategies
10
10
 
11
11
  logger = logging.getLogger(__name__)
12
12
 
@@ -42,12 +42,27 @@ def delete_events(self, *args, **kwargs):
42
42
  def _send_events(_, event_ids: list[int] = None, suppress_events_error: bool | None = False,
43
43
  batch_size: int | None = 50):
44
44
  """ Отправка уведомлений во внешнюю систему, например в kafka или rabbit """
45
- StrategyClass = utils.load_class(settings.SEND_EVENTS_STRATEGY)
46
- strategy = StrategyClass(batch_size=batch_size)
47
- error_messages = strategy.send_events(event_ids=event_ids)
48
- if not suppress_events_error and error_messages:
49
- error_ids = ','.join([str(e) for e in error_messages])
50
- raise Exception('Возникли ошибки при отправке сообщений с ID "%s".', error_ids)
45
+ class_strategies = list(
46
+ strategies.get_pending_outbox_messages(event_ids=event_ids).values_list('strategy', flat=True).distinct(
47
+ 'strategy'))
48
+ for class_strategy in class_strategies:
49
+ if class_strategy is None:
50
+ external_filter = Q(strategy__isnull=True)
51
+ class_strategy = settings.SEND_EVENTS_STRATEGY
52
+ else:
53
+ external_filter = Q(strategy=class_strategy)
54
+ try:
55
+ StrategyClass = utils.load_class(class_strategy)
56
+
57
+ strategy = StrategyClass(batch_size=batch_size)
58
+ error_messages = strategy.send_events(event_ids=event_ids, external_filter=external_filter)
59
+
60
+ if not suppress_events_error and error_messages:
61
+ error_ids = ','.join([str(e) for e in error_messages])
62
+ raise Exception('Возникли ошибки при отправке сообщений с ID "%s".', error_ids)
63
+ except Exception as ex:
64
+ logger.error('Возникла ошибка при обработке стратегии "%s".', class_strategy)
65
+ logger.exception(ex)
51
66
 
52
67
 
53
68
  def _expiring_events(_, expiration_delta_in_hours: int | None = 12):
@@ -1,3 +1,4 @@
1
+ import uuid
1
2
  from datetime import timedelta
2
3
  from unittest.mock import Mock
3
4
 
@@ -10,6 +11,29 @@ from celery_misc.transactional_outbox import outbox_utils, models, enums, tasks
10
11
  pytestmark = pytest.mark.django_db
11
12
 
12
13
 
14
+ def _validate_db_outbox_message(message_id, idempotency_key, payload,
15
+ aggregate_id: int | None = 123, event_type: str | None = 'order.created',
16
+ aggregate_type: str | None = 'Mock',
17
+ strategy: str | None = None,
18
+ meta_data: dict | None = None):
19
+ db_message = models.OutboxMessage.objects.filter(id=message_id).first()
20
+ assert db_message
21
+ assert db_message.idempotency_key == idempotency_key
22
+ assert db_message.aggregate_type == aggregate_type
23
+ assert db_message.aggregate_id == str(aggregate_id)
24
+ assert db_message.event_type == event_type
25
+ assert db_message.payload == payload
26
+ assert db_message.strategy == strategy
27
+
28
+ assert db_message.status == enums.EventStatuses.SENT
29
+ assert db_message.created_at is not None
30
+ assert db_message.sent_at is not None
31
+ assert db_message.error is None
32
+ assert db_message.meta_data == meta_data
33
+
34
+ return db_message
35
+
36
+
13
37
  @pytest.mark.parametrize('strategy', [
14
38
  'celery_misc.transactional_outbox.strategies.DummyCheckStatusStrategy',
15
39
  'celery_misc.transactional_outbox.strategies.DummyBlockBatchStrategy',
@@ -20,26 +44,47 @@ def test_send_events(mocker, strategy):
20
44
  aggregate_id = 123
21
45
  event_type = 'order.created'
22
46
  payload = {'total': 100}
23
- idempotency_key = 'test1234'
47
+ idempotency_key = str(uuid.uuid4())
48
+ meta_data = {
49
+ 'test': 12345
50
+ }
24
51
  with outbox_utils.OutboxEvent(event_type) as outbox_guard:
25
52
  # Здесь instance будет правильно типизирован как Order
26
53
  order = Mock()
27
54
  order.id = aggregate_id
28
55
 
29
- outbox_guard.send(order, payload, idempotency_key=idempotency_key)
56
+ outbox_guard.send(order, payload, idempotency_key=idempotency_key, meta_data=meta_data)
57
+ message_id = outbox_guard.event_ids[0]
58
+ _validate_db_outbox_message(message_id, idempotency_key, payload,
59
+ aggregate_id=aggregate_id, event_type=event_type, meta_data=meta_data)
30
60
 
31
- db_message = models.OutboxMessage.objects.filter(id=outbox_guard.message_id).first()
32
- assert db_message
33
- assert db_message.idempotency_key == idempotency_key
34
- assert db_message.aggregate_type == 'Mock'
35
- assert db_message.aggregate_id == str(aggregate_id)
36
- assert db_message.event_type == event_type
37
- assert db_message.payload == payload
38
61
 
39
- assert db_message.status == enums.EventStatuses.SENT
40
- assert db_message.created_at is not None
41
- assert db_message.sent_at is not None
42
- assert db_message.error is None
62
+ @pytest.mark.parametrize('strategy', [
63
+ 'celery_misc.transactional_outbox.strategies.DummyCheckStatusStrategy',
64
+ 'celery_misc.transactional_outbox.strategies.DummyBlockBatchStrategy',
65
+ ])
66
+ def test_send_events__bulk(mocker, strategy):
67
+ """ Создание и отправка пакета событий """
68
+ mocker.patch('celery_misc.transactional_outbox.settings.SEND_EVENTS_STRATEGY', strategy)
69
+ aggregate_id = 123
70
+ event_type = 'order.created'
71
+ total_count = 100
72
+ payload = [{'i': i} for i in range(total_count)]
73
+ idempotency_key = [str(uuid.uuid4()) for _ in range(total_count)]
74
+ meta_data = [{'meta': f'meta_{i}'} for i in range(int(total_count / 2))]
75
+ with outbox_utils.OutboxEvent(event_type) as outbox_guard:
76
+ # Здесь instance будет правильно типизирован как Order
77
+ order = Mock()
78
+ order.id = aggregate_id
79
+
80
+ outbox_guard.send(order, payload, idempotency_key=idempotency_key, meta_data=meta_data)
81
+
82
+ assert outbox_guard.event_ids
83
+ assert len(outbox_guard.event_ids) == total_count
84
+ for i, message_id in enumerate(outbox_guard.event_ids):
85
+ meta = meta_data[i] if i < len(meta_data) else None
86
+ _validate_db_outbox_message(message_id, idempotency_key[i], payload[i], aggregate_id=aggregate_id,
87
+ event_type=event_type, meta_data=meta)
43
88
 
44
89
 
45
90
  @pytest.mark.parametrize('strategy', [
@@ -64,7 +109,8 @@ def test_send_events__with_error(mocker, strategy):
64
109
 
65
110
  outbox_guard.send(order, payload, idempotency_key=idempotency_key)
66
111
 
67
- db_message = models.OutboxMessage.objects.filter(id=outbox_guard.message_id).first()
112
+ message_id = outbox_guard.event_ids[0]
113
+ db_message = models.OutboxMessage.objects.filter(id=message_id).first()
68
114
  assert db_message
69
115
  assert db_message.idempotency_key == idempotency_key
70
116
  assert db_message.aggregate_type == 'Mock'
@@ -85,6 +131,7 @@ def test_send_events__with_error(mocker, strategy):
85
131
  def test_send_events__validate_status(mocker, strategy):
86
132
  """ Проверка статуса события при отправке """
87
133
  mocker.patch('celery_misc.transactional_outbox.settings.SEND_EVENTS_STRATEGY', strategy)
134
+
88
135
  message_kwargs = {
89
136
  'aggregate_type': 'Order',
90
137
  'aggregate_id': '123',
@@ -133,14 +180,10 @@ def test_expiring_events():
133
180
  now = timezone.now()
134
181
  expiration_date = now - timedelta(hours=25)
135
182
  objs = models.OutboxMessage.objects.bulk_create([
136
- models.OutboxMessage(idempotency_key=str(uuid6.uuid7()), status=enums.EventStatuses.PENDING,
137
- created_at=now, **message_kwargs),
138
- models.OutboxMessage(idempotency_key=str(uuid6.uuid7()), status=enums.EventStatuses.PENDING,
139
- created_at=now, **message_kwargs),
140
- models.OutboxMessage(idempotency_key=str(uuid6.uuid7()), status=enums.EventStatuses.PENDING,
141
- created_at=now, **message_kwargs),
142
- models.OutboxMessage(idempotency_key=str(uuid6.uuid7()), status=enums.EventStatuses.PENDING,
143
- created_at=now, **message_kwargs),
183
+ models.OutboxMessage(idempotency_key=str(uuid6.uuid7()), status=enums.EventStatuses.PENDING, **message_kwargs),
184
+ models.OutboxMessage(idempotency_key=str(uuid6.uuid7()), status=enums.EventStatuses.PENDING, **message_kwargs),
185
+ models.OutboxMessage(idempotency_key=str(uuid6.uuid7()), status=enums.EventStatuses.PENDING, **message_kwargs),
186
+ models.OutboxMessage(idempotency_key=str(uuid6.uuid7()), status=enums.EventStatuses.PENDING, **message_kwargs),
144
187
  ])
145
188
  event_1, expired_event_1, event_2, expired_event_2 = objs
146
189
 
@@ -173,3 +216,79 @@ def test_expiring_events():
173
216
  assert not models.OutboxMessage.objects.filter(id=event_1.id).exists()
174
217
  assert not models.OutboxMessage.objects.filter(id=expired_event_1.id).exists()
175
218
  assert not models.OutboxMessage.objects.filter(id=expired_event_2.id).exists()
219
+
220
+
221
+ def test_send_events__with_transaction_error(mocker):
222
+ """ Во время создания события произошла ошибка """
223
+ mocker.patch('celery_misc.transactional_outbox.settings.SEND_EVENTS_STRATEGY',
224
+ 'celery_misc.transactional_outbox.strategies.DummyCheckStatusStrategy')
225
+
226
+ try:
227
+ with outbox_utils.OutboxEvent('order.created') as outbox_guard:
228
+ # Здесь instance будет правильно типизирован как Order
229
+ order = models.OutboxMessage.objects.create(**{
230
+ 'aggregate_type': 'Order',
231
+ 'aggregate_id': '1',
232
+ 'event_type': 'test',
233
+ 'payload': {}
234
+ })
235
+ order_id = order.id
236
+ outbox_guard.send(order, {'total': 100})
237
+ raise Exception('Test exc')
238
+ except:
239
+ pass
240
+
241
+ assert not models.OutboxMessage.objects.filter(id=order_id).exists()
242
+
243
+
244
+ def test_send_events__strategy(mocker):
245
+ """ Создание и отправка события с указанием стратегии """
246
+ mocker.patch('celery_misc.transactional_outbox.settings.SEND_EVENTS_STRATEGY',
247
+ 'celery_misc.transactional_outbox.strategies.DummyCheckStatusStrategy')
248
+ strategy = 'celery_misc.transactional_outbox.strategies.DummyBlockBatchStrategy'
249
+
250
+ publish_message_mocker = mocker.patch(f'{strategy}.publish_message')
251
+ aggregate_id = 123
252
+ event_type = 'order.created'
253
+ payload = {'total': 100}
254
+ idempotency_key = str(uuid.uuid4())
255
+ meta_data = {
256
+ 'test': 12345
257
+ }
258
+ with outbox_utils.OutboxEvent(event_type, strategy=strategy) as outbox_guard:
259
+ # Здесь instance будет правильно типизирован как Order
260
+ order = Mock()
261
+ order.id = aggregate_id
262
+
263
+ outbox_guard.send(order, payload, idempotency_key=idempotency_key, meta_data=meta_data)
264
+ message_id = outbox_guard.event_ids[0]
265
+ db_message = _validate_db_outbox_message(message_id, idempotency_key, payload,
266
+ aggregate_id=aggregate_id, event_type=event_type,
267
+ meta_data=meta_data, strategy=strategy)
268
+ publish_message_mocker.assert_called_once_with(db_message)
269
+
270
+
271
+ def test_expiring_events__multy_strategy(mocker):
272
+ """ Создание и отправка нескольких событий с указанием разных стратегии """
273
+ mocker.patch('celery_misc.transactional_outbox.settings.SEND_EVENTS_STRATEGY',
274
+ 'celery_misc.transactional_outbox.strategies.DummyCheckStatusStrategy')
275
+ publish_message_mocker_1 = mocker.patch(
276
+ f'celery_misc.transactional_outbox.strategies.DummyBlockBatchStrategy.publish_message')
277
+ publish_message_mocker_2 = mocker.patch(
278
+ f'celery_misc.transactional_outbox.strategies.DummyCheckStatusStrategy.publish_message')
279
+
280
+ message_kwargs = {
281
+ 'aggregate_type': 'Order',
282
+ 'aggregate_id': '123',
283
+ 'event_type': 'order.created',
284
+ 'payload': {'total': 100}
285
+ }
286
+ db_message_1, db_message_2 = models.OutboxMessage.objects.bulk_create([
287
+ models.OutboxMessage(idempotency_key=str(uuid6.uuid7()), status=enums.EventStatuses.PENDING,
288
+ strategy='celery_misc.transactional_outbox.strategies.DummyBlockBatchStrategy',
289
+ **message_kwargs),
290
+ models.OutboxMessage(idempotency_key=str(uuid6.uuid7()), status=enums.EventStatuses.PENDING, **message_kwargs),
291
+ ])
292
+ tasks.send_events.apply_async(kwargs={'event_ids': [db_message_1.id, db_message_2.id]})
293
+ publish_message_mocker_1.assert_called_once_with(db_message_1)
294
+ publish_message_mocker_2.assert_called_once_with(db_message_2)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: celery-misc
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: Python async tools for Celery
5
5
  Author: Anton Gorinenko
6
6
  Author-email: anton.gorinenko@gmail.com
@@ -38,6 +38,8 @@ celery_misc/transactional_outbox/settings.py
38
38
  celery_misc/transactional_outbox/strategies.py
39
39
  celery_misc/transactional_outbox/tasks.py
40
40
  celery_misc/transactional_outbox/migrations/0001_initial.py
41
+ celery_misc/transactional_outbox/migrations/0002_outboxmessage_meta_data_outboxmessage_strategy.py
42
+ celery_misc/transactional_outbox/migrations/0003_alter_outboxmessage_idempotency_key.py
41
43
  celery_misc/transactional_outbox/migrations/__init__.py
42
44
  celery_misc/transactional_outbox/tests/__init__.py
43
45
  celery_misc/transactional_outbox/tests/test_utils.py
@@ -18,7 +18,7 @@ def long_description():
18
18
 
19
19
  setuptools.setup(
20
20
  name='celery-misc',
21
- version='1.0.2',
21
+ version='1.0.4',
22
22
  author='Anton Gorinenko',
23
23
  author_email='anton.gorinenko@gmail.com',
24
24
  description='Python async tools for Celery',
@@ -1,68 +0,0 @@
1
- from typing import TypeVar
2
-
3
- import uuid6
4
- from django.db import transaction
5
- from django.db.models import Model
6
-
7
- from celery_misc.transactional_outbox import models, tasks, enums
8
-
9
- T = TypeVar('T', bound=Model)
10
-
11
-
12
- def resend_events(event_ids: list[int]):
13
- """ Повторная отправка сообщений """
14
- if not event_ids:
15
- raise ValueError('Event_ids is none or empty.')
16
-
17
- models.OutboxMessage.objects.filter(id__in=event_ids).update(status=enums.EventStatuses.PENDING, sent_at=None)
18
- tasks.send_events_only_ones.apply_async(kwargs={'event_ids': event_ids})
19
-
20
-
21
- class OutboxEvent:
22
- def __init__(self, event_type: str):
23
- """ Создание события для отправки """
24
- self.event_type = event_type
25
- self.payload = None
26
- self.idempotency_key = None
27
- self.instance = None
28
- self.message_id = None
29
-
30
- def __enter__(self):
31
- return self
32
-
33
- def __exit__(self, exc_type, exc_val, exc_tb):
34
- if exc_type is None:
35
- if not self.instance or not self.payload:
36
- raise ValueError('Необходимо вызвать функцию send(instance, payload) внутри контекстного менеджера.')
37
- self._create_outbox_message()
38
- return True
39
- return False
40
-
41
- def send(self, instance: T, payload: dict, idempotency_key: str | None = None):
42
- self.instance = instance
43
- self.payload = payload
44
- self.idempotency_key = idempotency_key
45
-
46
- def _create_outbox_message(self):
47
- with transaction.atomic():
48
- aggregate_type = str(type(self.instance).__name__)
49
- aggregate_id = self.instance.id
50
- message_kwargs = {
51
- 'aggregate_type': aggregate_type,
52
- 'aggregate_id': aggregate_id,
53
- 'event_type': self.event_type,
54
- 'payload': self.payload
55
- }
56
-
57
- if self.idempotency_key:
58
- message, created = models.OutboxMessage.objects.get_or_create(idempotency_key=self.idempotency_key,
59
- **message_kwargs)
60
- else:
61
- message_kwargs['idempotency_key'] = str(uuid6.uuid7())
62
- message = models.OutboxMessage.objects.create(**message_kwargs)
63
- created = True
64
-
65
- if created:
66
- tasks.send_events_only_ones.apply_async(kwargs={'event_ids': [message.id]})
67
-
68
- self.message_id = message.id
File without changes
File without changes
File without changes