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.
- {celery_misc-1.0.2 → celery_misc-1.0.4}/PKG-INFO +1 -1
- celery_misc-1.0.4/celery_misc/transactional_outbox/migrations/0002_outboxmessage_meta_data_outboxmessage_strategy.py +23 -0
- celery_misc-1.0.4/celery_misc/transactional_outbox/migrations/0003_alter_outboxmessage_idempotency_key.py +18 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/models.py +7 -2
- celery_misc-1.0.4/celery_misc/transactional_outbox/outbox_utils.py +125 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/strategies.py +20 -9
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/tasks.py +22 -7
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/tests/test_utils.py +141 -22
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc.egg-info/PKG-INFO +1 -1
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc.egg-info/SOURCES.txt +2 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/setup.py +1 -1
- celery_misc-1.0.2/celery_misc/transactional_outbox/outbox_utils.py +0 -68
- {celery_misc-1.0.2 → celery_misc-1.0.4}/LICENSE +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/README.md +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/__init__.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/__init__.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/admin.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/apps.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/enums.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/migrations/0001_initial.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/migrations/__init__.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/models.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/monitoring_utils.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/query_sets.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/signals.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/task_signals.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/tasks.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/tests/__init__.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/tests/test_tasks.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/tests/test_utils.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_utils.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/django_utils.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/errors.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/model_utils.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/signal_utils.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/__init__.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/admin.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/apps.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/enums.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/migrations/0001_initial.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/migrations/__init__.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/settings.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/tests/__init__.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/utils.py +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc.egg-info/dependency_links.txt +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc.egg-info/requires.txt +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc.egg-info/top_level.txt +0 -0
- {celery_misc-1.0.2 → celery_misc-1.0.4}/setup.cfg +0 -0
|
@@ -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=
|
|
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 =
|
|
49
|
-
|
|
50
|
-
|
|
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,
|
|
102
|
+
def send_events(self, *args, **kwargs) -> list[int]:
|
|
92
103
|
with transaction.atomic():
|
|
93
|
-
return super().send_events(
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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):
|
{celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/tests/test_utils.py
RENAMED
|
@@ -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 =
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
models.OutboxMessage(idempotency_key=str(uuid6.uuid7()), status=enums.EventStatuses.PENDING,
|
|
139
|
-
|
|
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)
|
|
@@ -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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/migrations/0001_initial.py
RENAMED
|
File without changes
|
{celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/celery_monitoring/migrations/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/migrations/0001_initial.py
RENAMED
|
File without changes
|
{celery_misc-1.0.2 → celery_misc-1.0.4}/celery_misc/transactional_outbox/migrations/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|