explicit-python-dlq 1.0.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.
- explicit/dlq/__init__.py +1 -0
- explicit/dlq/adapters/__init__.py +0 -0
- explicit/dlq/adapters/db.py +13 -0
- explicit/dlq/config.py +63 -0
- explicit/dlq/contrib/__init__.py +0 -0
- explicit/dlq/contrib/django/README.md +40 -0
- explicit/dlq/contrib/django/__init__.py +0 -0
- explicit/dlq/contrib/django/adapters/__init__.py +0 -0
- explicit/dlq/contrib/django/adapters/db.py +104 -0
- explicit/dlq/contrib/django/apps.py +12 -0
- explicit/dlq/contrib/django/migrations/0001_initial.py +60 -0
- explicit/dlq/contrib/django/migrations/__init__.py +0 -0
- explicit/dlq/contrib/django/models.py +45 -0
- explicit/dlq/contrib/drf/README.md +32 -0
- explicit/dlq/contrib/drf/__init__.py +0 -0
- explicit/dlq/contrib/drf/serializers.py +45 -0
- explicit/dlq/contrib/drf/views.py +72 -0
- explicit/dlq/domain/__init__.py +0 -0
- explicit/dlq/domain/commands.py +37 -0
- explicit/dlq/domain/factories.py +81 -0
- explicit/dlq/domain/model.py +71 -0
- explicit/dlq/domain/services.py +64 -0
- explicit/dlq/infrastructure/__init__.py +0 -0
- explicit/dlq/infrastructure/handlers.py +72 -0
- explicit/dlq/py.typed +0 -0
- explicit/dlq/services/__init__.py +0 -0
- explicit/dlq/services/handlers.py +95 -0
- explicit/dlq/types.py +15 -0
- explicit_python_dlq-1.0.0.dist-info/METADATA +105 -0
- explicit_python_dlq-1.0.0.dist-info/RECORD +32 -0
- explicit_python_dlq-1.0.0.dist-info/WHEEL +5 -0
- explicit_python_dlq-1.0.0.dist-info/top_level.txt +1 -0
explicit/dlq/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Адаптеры для работы с Dead Letter Queue (DLQ) в базе данных."""
|
|
2
|
+
|
|
3
|
+
from abc import (
|
|
4
|
+
ABCMeta,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
from explicit.adapters.db import (
|
|
8
|
+
AbstractRepository,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AbstractDLQRepository(AbstractRepository, metaclass=ABCMeta):
|
|
13
|
+
"""Абстрактный репозиторий для работы с DLQ."""
|
explicit/dlq/config.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Конфигурация DLQ."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import (
|
|
4
|
+
dataclass,
|
|
5
|
+
)
|
|
6
|
+
from typing import (
|
|
7
|
+
TYPE_CHECKING,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from pydantic import (
|
|
11
|
+
ConfigDict,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from explicit.dlq.domain import (
|
|
15
|
+
commands,
|
|
16
|
+
)
|
|
17
|
+
from explicit.dlq.services.handlers import (
|
|
18
|
+
DLQHandlers,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from explicit.dlq.adapters.db import (
|
|
24
|
+
AbstractDLQRepository,
|
|
25
|
+
)
|
|
26
|
+
from explicit.dlq.types import (
|
|
27
|
+
DLQMessageDispatcher,
|
|
28
|
+
)
|
|
29
|
+
from explicit.messagebus import (
|
|
30
|
+
MessageBus,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class DLQConfig:
|
|
36
|
+
"""Конфигурация DLQ."""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
39
|
+
|
|
40
|
+
bus: 'MessageBus'
|
|
41
|
+
repository: 'AbstractDLQRepository'
|
|
42
|
+
message_dispatcher: 'DLQMessageDispatcher'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def configure_dlq(config: DLQConfig):
|
|
46
|
+
"""Настроить DLQ."""
|
|
47
|
+
config.bus.get_uow().register_repositories(('dead_letters', config.repository))
|
|
48
|
+
|
|
49
|
+
handlers = DLQHandlers(bus=config.bus, message_dispatcher=config.message_dispatcher)
|
|
50
|
+
|
|
51
|
+
config.bus.add_command_handler(
|
|
52
|
+
commands.RegisterDeadLetter,
|
|
53
|
+
handlers.register_dead_letter, # type: ignore[attr-defined,arg-type]
|
|
54
|
+
)
|
|
55
|
+
config.bus.add_command_handler(
|
|
56
|
+
commands.ProcessDeadLetter,
|
|
57
|
+
handlers.process_dead_letter, # type: ignore[attr-defined,arg-type]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
config.bus.add_command_handler(
|
|
61
|
+
commands.UpdateDeadLetterRawMessage,
|
|
62
|
+
handlers.update_raw_message_value, # type: ignore[attr-defined,arg-type]
|
|
63
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
## Набор компонентов для интеграции explicit.dlq с Django.
|
|
2
|
+
Содержит реализацию репозитория необработанных сообщений.
|
|
3
|
+
|
|
4
|
+
## Пример подключения
|
|
5
|
+
testapp/settings.py:
|
|
6
|
+
```python
|
|
7
|
+
INSTALLED_APPS = [
|
|
8
|
+
# другие приложения
|
|
9
|
+
'explicit.dlq.contrib.django', # подключение приложения с моделью DeadLetter
|
|
10
|
+
'testapp.core', # настройка компонентов из explicit.dlq
|
|
11
|
+
]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
testapp/dlq/apps.py:
|
|
15
|
+
```python
|
|
16
|
+
|
|
17
|
+
from datetime import timedelta
|
|
18
|
+
|
|
19
|
+
from django.apps import AppConfig as AppConfigBase
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AppConfig(AppConfigBase):
|
|
23
|
+
name = __package__
|
|
24
|
+
|
|
25
|
+
def ready(self):
|
|
26
|
+
self._configure_dlq()
|
|
27
|
+
|
|
28
|
+
def _configure_dlq(self):
|
|
29
|
+
from explicit.dlq.config import DLQConfig, configure_dlq
|
|
30
|
+
|
|
31
|
+
# реализация репозитория на базе Django ORM
|
|
32
|
+
from explicit.dlq.contrib.django.adapters.db import Repository
|
|
33
|
+
config = DLQConfig(
|
|
34
|
+
# ...
|
|
35
|
+
repository=Repository(),
|
|
36
|
+
# ...
|
|
37
|
+
)
|
|
38
|
+
# регистрация репозитория и регистрация обработчиков команд
|
|
39
|
+
configure_dlq(config)
|
|
40
|
+
```
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Адаптеры для работы с Dead Letter Queue (DLQ) в базе данных."""
|
|
2
|
+
|
|
3
|
+
from typing import (
|
|
4
|
+
TYPE_CHECKING,
|
|
5
|
+
Iterator,
|
|
6
|
+
Union,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
from django.core.exceptions import (
|
|
10
|
+
ObjectDoesNotExist,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from explicit.dlq.adapters.db import (
|
|
14
|
+
AbstractDLQRepository,
|
|
15
|
+
)
|
|
16
|
+
from explicit.dlq.domain.model import (
|
|
17
|
+
DeadLetter,
|
|
18
|
+
DeadLetterNotFound,
|
|
19
|
+
)
|
|
20
|
+
from explicit.domain import (
|
|
21
|
+
asdict,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from uuid import (
|
|
27
|
+
UUID,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from explicit.dlq.contrib.django import (
|
|
31
|
+
models as db,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Repository(AbstractDLQRepository):
|
|
36
|
+
"""Репозиторий DeadLetterQueue."""
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def _base_qs(self):
|
|
40
|
+
# pylint: disable-next=import-outside-toplevel
|
|
41
|
+
from explicit.dlq.contrib.django.models import (
|
|
42
|
+
DeadLetter as DBDeadLetter,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return DBDeadLetter.objects.order_by('_first_attempted_at')
|
|
46
|
+
|
|
47
|
+
def _to_domain(self, dbinstance: 'db.DeadLetter') -> DeadLetter:
|
|
48
|
+
return DeadLetter(
|
|
49
|
+
id=dbinstance.id,
|
|
50
|
+
raw_message_value=bytes(dbinstance.raw_message_value),
|
|
51
|
+
raw_message_key=bytes(dbinstance.raw_message_key) if dbinstance.raw_message_key else None,
|
|
52
|
+
topic=dbinstance.topic,
|
|
53
|
+
attempts=tuple(dbinstance.attempts),
|
|
54
|
+
processed_at=dbinstance.processed_at,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def _to_db(self, modelinstance: DeadLetter) -> DeadLetter:
|
|
58
|
+
# pylint: disable-next=import-outside-toplevel
|
|
59
|
+
from explicit.dlq.contrib.django.models import (
|
|
60
|
+
DeadLetter as DBDeadLetter,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
assert isinstance(modelinstance, DeadLetter)
|
|
64
|
+
db_instance, _ = DBDeadLetter.objects.update_or_create(
|
|
65
|
+
pk=modelinstance.id,
|
|
66
|
+
defaults=asdict(modelinstance, exclude={'id', 'attempts'})
|
|
67
|
+
| {
|
|
68
|
+
'attempts': [
|
|
69
|
+
asdict(attempt) | {'failed_at': attempt.failed_at.isoformat()} for attempt in modelinstance.attempts
|
|
70
|
+
],
|
|
71
|
+
'_first_attempted_at': modelinstance.attempts[0].failed_at,
|
|
72
|
+
'_attempts_count': len(modelinstance.attempts),
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
return self.get_object_by_id(db_instance.pk)
|
|
76
|
+
|
|
77
|
+
def add(self, obj: DeadLetter) -> DeadLetter:
|
|
78
|
+
"""Добавить сообщение."""
|
|
79
|
+
return self._to_db(obj)
|
|
80
|
+
|
|
81
|
+
def update(self, obj: DeadLetter) -> DeadLetter:
|
|
82
|
+
"""Обновить сообщение."""
|
|
83
|
+
assert isinstance(obj, DeadLetter)
|
|
84
|
+
|
|
85
|
+
return self._to_db(obj)
|
|
86
|
+
|
|
87
|
+
def delete(self, obj: DeadLetter) -> None:
|
|
88
|
+
"""Удалить сообщение."""
|
|
89
|
+
assert isinstance(obj, DeadLetter)
|
|
90
|
+
|
|
91
|
+
self._base_qs.filter(pk=obj.id).delete()
|
|
92
|
+
|
|
93
|
+
def get_all_objects(self) -> Iterator[DeadLetter]:
|
|
94
|
+
"""Получить все сообщения."""
|
|
95
|
+
for db_instance in self._base_qs.iterator():
|
|
96
|
+
yield self._to_domain(db_instance)
|
|
97
|
+
|
|
98
|
+
def get_object_by_id(self, identifier: 'Union[UUID, str]') -> DeadLetter:
|
|
99
|
+
"""Получить сообщение по идентификатору."""
|
|
100
|
+
try:
|
|
101
|
+
db_instance = self._base_qs.get(pk=identifier)
|
|
102
|
+
return self._to_domain(db_instance)
|
|
103
|
+
except ObjectDoesNotExist as exc:
|
|
104
|
+
raise DeadLetterNotFound from exc
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# pylint: disable=missing-module-docstring,invalid-name
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
from django import (
|
|
5
|
+
VERSION as DJANGO_VERSION,
|
|
6
|
+
)
|
|
7
|
+
from django.db import (
|
|
8
|
+
migrations,
|
|
9
|
+
models,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if DJANGO_VERSION >= (4, 0):
|
|
14
|
+
from django.db.models import (
|
|
15
|
+
JSONField,
|
|
16
|
+
)
|
|
17
|
+
else:
|
|
18
|
+
from django.contrib.postgres.fields import (
|
|
19
|
+
JSONField,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Migration(migrations.Migration):
|
|
24
|
+
initial = True
|
|
25
|
+
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
operations = [
|
|
29
|
+
migrations.CreateModel(
|
|
30
|
+
name='DeadLetter',
|
|
31
|
+
fields=[
|
|
32
|
+
(
|
|
33
|
+
'id',
|
|
34
|
+
models.UUIDField(
|
|
35
|
+
default=uuid.uuid4,
|
|
36
|
+
editable=False,
|
|
37
|
+
primary_key=True,
|
|
38
|
+
serialize=False,
|
|
39
|
+
verbose_name='Идентификатор UUID',
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
('raw_message_value', models.BinaryField(verbose_name='Содержимое сообщения')),
|
|
43
|
+
('raw_message_key', models.BinaryField(verbose_name='Ключ сообщения', null=True, blank=True)),
|
|
44
|
+
('topic', models.CharField(max_length=256, verbose_name='Топик сообщения')),
|
|
45
|
+
('attempts', JSONField(verbose_name='Попытки обработки сообщения')),
|
|
46
|
+
(
|
|
47
|
+
'processed_at',
|
|
48
|
+
models.DateTimeField(blank=True, null=True, verbose_name='Время успешной обработки сообщения'),
|
|
49
|
+
),
|
|
50
|
+
('_first_attempted_at', models.DateTimeField(db_index=True)),
|
|
51
|
+
('_attempts_count', models.IntegerField(db_index=True)),
|
|
52
|
+
],
|
|
53
|
+
options={
|
|
54
|
+
'verbose_name': 'Необработанное сообщение',
|
|
55
|
+
'verbose_name_plural': 'Необработанные сообщения',
|
|
56
|
+
'indexes': [models.Index(fields=['_attempts_count', '_first_attempted_at'], name='dlq_attempts_idx')],
|
|
57
|
+
'db_table': 'dlq_dead_letter',
|
|
58
|
+
},
|
|
59
|
+
),
|
|
60
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Модели DLQ."""
|
|
2
|
+
|
|
3
|
+
from uuid import (
|
|
4
|
+
uuid4,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
from django import (
|
|
8
|
+
VERSION as DJANGO_VERSION,
|
|
9
|
+
)
|
|
10
|
+
from django.db import (
|
|
11
|
+
models,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from explicit.dlq.domain.model import (
|
|
15
|
+
DeadLetter as DomainDeadLetter,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if DJANGO_VERSION >= (4, 0):
|
|
20
|
+
from django.db.models import JSONField # pylint: disable=ungrouped-imports
|
|
21
|
+
else:
|
|
22
|
+
from django.contrib.postgres.fields import (
|
|
23
|
+
JSONField,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DeadLetter(models.Model):
|
|
28
|
+
"""Недоставленное/необработанное сообщение."""
|
|
29
|
+
|
|
30
|
+
id = models.UUIDField(primary_key=True, default=uuid4, editable=False, verbose_name=DomainDeadLetter.id.title)
|
|
31
|
+
raw_message_value = models.BinaryField(verbose_name=DomainDeadLetter.raw_message_value.title)
|
|
32
|
+
raw_message_key = models.BinaryField(verbose_name=DomainDeadLetter.raw_message_key.title, null=True, blank=True)
|
|
33
|
+
topic = models.CharField(verbose_name=DomainDeadLetter.topic.title, max_length=DomainDeadLetter.topic.max_length)
|
|
34
|
+
attempts = JSONField(verbose_name=DomainDeadLetter.attempts.title)
|
|
35
|
+
processed_at = models.DateTimeField(verbose_name=DomainDeadLetter.processed_at.title, null=True, blank=True)
|
|
36
|
+
|
|
37
|
+
# служебные поля используемые для фильтрации и сортировки, чтобы не лезть в json
|
|
38
|
+
_first_attempted_at = models.DateTimeField(db_index=True)
|
|
39
|
+
_attempts_count = models.IntegerField(db_index=True)
|
|
40
|
+
|
|
41
|
+
class Meta: # noqa: D106
|
|
42
|
+
verbose_name = 'Необработанное сообщение'
|
|
43
|
+
verbose_name_plural = 'Необработанные сообщения'
|
|
44
|
+
indexes = [models.Index(fields=['_attempts_count', '_first_attempted_at'], name='dlq_attempts_idx')]
|
|
45
|
+
db_table = 'dlq_dead_letter'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Реализация REST эндпоинтов DLQ.
|
|
2
|
+
|
|
3
|
+
## Пример подключения
|
|
4
|
+
testapp/rest/dlq/views.py:
|
|
5
|
+
```python
|
|
6
|
+
from explicit.dlq.contrib.drf.views import BaseDeadLetterQueueViewSet
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DeadLetterQueueViewSet(BaseDeadLetterQueueViewSet):
|
|
10
|
+
"""Необработанные сообщения."""
|
|
11
|
+
|
|
12
|
+
def bus_handle(self, command):
|
|
13
|
+
"""Обработчик команды."""
|
|
14
|
+
from testapp import core
|
|
15
|
+
|
|
16
|
+
return core.bus.handle(command)
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
testapp/rest/urls.py:
|
|
21
|
+
```python
|
|
22
|
+
from rest_framework.routers import SimpleRouter
|
|
23
|
+
|
|
24
|
+
from testapp.rest.dlq.views import DeadLetterQueueViewSet
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
router = SimpleRouter()
|
|
28
|
+
|
|
29
|
+
router.register('dlq', DeadLetterQueueViewSet, basename='dlq')
|
|
30
|
+
|
|
31
|
+
urlpatterns = router.urls
|
|
32
|
+
````
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Сериализаторы для работы DeadLetter."""
|
|
2
|
+
|
|
3
|
+
# pylint: disable=abstract-method
|
|
4
|
+
from rest_framework import (
|
|
5
|
+
serializers,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
from explicit.dlq.contrib.django.models import (
|
|
9
|
+
DeadLetter,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _AttemptSerializer(serializers.Serializer):
|
|
14
|
+
"""Сериализатор попытки обработки сообщения."""
|
|
15
|
+
|
|
16
|
+
failed_at = serializers.DateTimeField(label='Время неудачной попытки')
|
|
17
|
+
error_message = serializers.CharField(label='Сообщение об ошибке')
|
|
18
|
+
traceback = serializers.CharField()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DeadLetterSerializer(serializers.ModelSerializer):
|
|
22
|
+
"""Сериализатор необработанного сообщения."""
|
|
23
|
+
|
|
24
|
+
raw_message_value = serializers.CharField(allow_blank=False)
|
|
25
|
+
raw_message_key = serializers.CharField(allow_blank=True, allow_null=True, read_only=True)
|
|
26
|
+
attempts = _AttemptSerializer(many=True, read_only=True)
|
|
27
|
+
|
|
28
|
+
def to_internal_value(self, data): # noqa: D102
|
|
29
|
+
ret = super().to_internal_value(data)
|
|
30
|
+
ret['raw_message_value'] = ret['raw_message_value'].encode()
|
|
31
|
+
if ret.get('raw_message_key'):
|
|
32
|
+
ret['raw_message_key'] = ret['raw_message_key'].encode()
|
|
33
|
+
return ret
|
|
34
|
+
|
|
35
|
+
def to_representation(self, instance): # noqa: D102
|
|
36
|
+
ret = super().to_representation(instance)
|
|
37
|
+
ret['raw_message_value'] = bytes(instance.raw_message_value).decode()
|
|
38
|
+
if instance.raw_message_key:
|
|
39
|
+
ret['raw_message_key'] = bytes(instance.raw_message_key).decode()
|
|
40
|
+
return ret
|
|
41
|
+
|
|
42
|
+
class Meta: # noqa: D106
|
|
43
|
+
model = DeadLetter
|
|
44
|
+
read_only_fields = ('id', 'raw_message_key', 'topic', 'attempts', 'processed_at')
|
|
45
|
+
exclude = ('_first_attempted_at', '_attempts_count')
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Вьюсеты DeadLetterQueue."""
|
|
2
|
+
|
|
3
|
+
from abc import (
|
|
4
|
+
abstractmethod,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
from rest_framework import (
|
|
8
|
+
status,
|
|
9
|
+
)
|
|
10
|
+
from rest_framework.decorators import (
|
|
11
|
+
action,
|
|
12
|
+
)
|
|
13
|
+
from rest_framework.filters import (
|
|
14
|
+
SearchFilter,
|
|
15
|
+
)
|
|
16
|
+
from rest_framework.response import (
|
|
17
|
+
Response,
|
|
18
|
+
)
|
|
19
|
+
from rest_framework.viewsets import (
|
|
20
|
+
ReadOnlyModelViewSet,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from explicit.django.domain.validation.exceptions import (
|
|
24
|
+
handle_domain_validation_error,
|
|
25
|
+
)
|
|
26
|
+
from explicit.dlq.contrib.django.models import (
|
|
27
|
+
DeadLetter,
|
|
28
|
+
)
|
|
29
|
+
from explicit.dlq.domain.commands import (
|
|
30
|
+
ProcessDeadLetter,
|
|
31
|
+
UpdateDeadLetterRawMessage,
|
|
32
|
+
)
|
|
33
|
+
from explicit.messagebus.commands import (
|
|
34
|
+
Command,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from .serializers import (
|
|
38
|
+
DeadLetterSerializer,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BaseDeadLetterQueueViewSet(ReadOnlyModelViewSet):
|
|
43
|
+
"""Необработанные сообщения."""
|
|
44
|
+
|
|
45
|
+
queryset = DeadLetter.objects.order_by('_first_attempted_at')
|
|
46
|
+
serializer_class = DeadLetterSerializer
|
|
47
|
+
filter_backends = (SearchFilter,)
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def bus_handle(self, command: Command):
|
|
51
|
+
"""Обработчик команды."""
|
|
52
|
+
|
|
53
|
+
@action(detail=True, methods=['post'])
|
|
54
|
+
@handle_domain_validation_error
|
|
55
|
+
def process(self, request, *args, **kwargs): # pylint: disable=unused-argument
|
|
56
|
+
"""Вызвать обработку сообщения."""
|
|
57
|
+
command = ProcessDeadLetter(id=kwargs.get(self.lookup_field))
|
|
58
|
+
|
|
59
|
+
self.bus_handle(command)
|
|
60
|
+
|
|
61
|
+
return Response(data=self.get_serializer(self.get_object()).data, status=status.HTTP_200_OK)
|
|
62
|
+
|
|
63
|
+
@handle_domain_validation_error
|
|
64
|
+
def partial_update(self, request, *args, **kwargs): # pylint: disable=unused-argument
|
|
65
|
+
"""Обновить данные сообщения."""
|
|
66
|
+
serializer = self.get_serializer(data=request.data)
|
|
67
|
+
serializer.is_valid(raise_exception=True)
|
|
68
|
+
command = UpdateDeadLetterRawMessage(id=kwargs.get(self.lookup_field), **serializer.validated_data)
|
|
69
|
+
|
|
70
|
+
self.bus_handle(command)
|
|
71
|
+
|
|
72
|
+
return Response(data=self.get_serializer(self.get_object()).data, status=status.HTTP_200_OK)
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Команды DLQ."""
|
|
2
|
+
|
|
3
|
+
from typing import (
|
|
4
|
+
Optional,
|
|
5
|
+
)
|
|
6
|
+
from uuid import (
|
|
7
|
+
UUID,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from explicit.messagebus.commands import (
|
|
11
|
+
Command,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RegisterDeadLetter(Command):
|
|
16
|
+
"""Зарегистрировать сообщение, которое не удалось обработать."""
|
|
17
|
+
|
|
18
|
+
topic: str
|
|
19
|
+
raw_message_value: bytes
|
|
20
|
+
raw_message_key: Optional[bytes] = None
|
|
21
|
+
exception: Exception
|
|
22
|
+
|
|
23
|
+
class Config: # noqa: D106
|
|
24
|
+
arbitrary_types_allowed = True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProcessDeadLetter(Command):
|
|
28
|
+
"""Выполнить попытку обработки сообщения."""
|
|
29
|
+
|
|
30
|
+
id: UUID
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UpdateDeadLetterRawMessage(ProcessDeadLetter):
|
|
34
|
+
"""Обновить содержимое сообщения в DLQ."""
|
|
35
|
+
|
|
36
|
+
id: UUID
|
|
37
|
+
raw_message_value: bytes
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""DTO и фабрики DLQ."""
|
|
2
|
+
|
|
3
|
+
import traceback
|
|
4
|
+
from datetime import (
|
|
5
|
+
datetime,
|
|
6
|
+
timezone,
|
|
7
|
+
)
|
|
8
|
+
from typing import (
|
|
9
|
+
Union,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from explicit.domain import (
|
|
13
|
+
Str,
|
|
14
|
+
UnsetUUID,
|
|
15
|
+
)
|
|
16
|
+
from explicit.domain.factories import (
|
|
17
|
+
AbstractDomainFactory,
|
|
18
|
+
DTOBase,
|
|
19
|
+
)
|
|
20
|
+
from explicit.domain.model import (
|
|
21
|
+
Unset,
|
|
22
|
+
unset,
|
|
23
|
+
)
|
|
24
|
+
from explicit.domain.types import (
|
|
25
|
+
NoneStr,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .model import (
|
|
29
|
+
Attempt,
|
|
30
|
+
DeadLetter,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AttemptDTO(DTOBase):
|
|
35
|
+
"""Объект передачи данных о попытке обработки сообщения."""
|
|
36
|
+
|
|
37
|
+
failed_at: Union[datetime, Unset] = unset
|
|
38
|
+
error_message: Str = unset
|
|
39
|
+
traceback: Str = unset
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DeadLetterDTO(DTOBase):
|
|
43
|
+
"""Объект передачи данных о необработанном сообщении."""
|
|
44
|
+
|
|
45
|
+
id: UnsetUUID = unset
|
|
46
|
+
raw_message_value: Union[bytes, Unset] = unset
|
|
47
|
+
raw_message_key: NoneStr = unset
|
|
48
|
+
topic: Str = unset
|
|
49
|
+
attempts: Union[list[AttemptDTO], Unset] = unset
|
|
50
|
+
processed_at: Union[datetime, None, Unset] = unset
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Factory(AbstractDomainFactory):
|
|
54
|
+
"""Фабрика для создания объектов предметной области DLQ."""
|
|
55
|
+
|
|
56
|
+
def create(self, data: DeadLetterDTO) -> DeadLetter:
|
|
57
|
+
"""Создать DLQ из DTO."""
|
|
58
|
+
params = data.dict()
|
|
59
|
+
|
|
60
|
+
return DeadLetter(**params)
|
|
61
|
+
|
|
62
|
+
def create_attempt_from_exception(self, exception: Exception) -> Attempt:
|
|
63
|
+
"""Создать запись о попытке обработки из исключения."""
|
|
64
|
+
return Attempt(
|
|
65
|
+
failed_at=datetime.now(tz=timezone.utc),
|
|
66
|
+
error_message=str(exception),
|
|
67
|
+
traceback=''.join(traceback.format_exception(type(exception), exception, exception.__traceback__)),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def create_from_exception(
|
|
71
|
+
self, raw_message_value: bytes, raw_message_key: Union[bytes, None], topic: str, exception: Exception
|
|
72
|
+
) -> DeadLetter:
|
|
73
|
+
"""Создать DLQ из исключения."""
|
|
74
|
+
attempt = self.create_attempt_from_exception(exception)
|
|
75
|
+
|
|
76
|
+
return DeadLetter(
|
|
77
|
+
raw_message_value=raw_message_value, raw_message_key=raw_message_key, topic=topic, attempts=(attempt,)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
factory = Factory()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Агрегаты и сущности для работы с недоставленными сообщениями."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import (
|
|
5
|
+
datetime,
|
|
6
|
+
timezone,
|
|
7
|
+
)
|
|
8
|
+
from typing import (
|
|
9
|
+
TYPE_CHECKING,
|
|
10
|
+
Optional,
|
|
11
|
+
Union,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from pydantic import (
|
|
15
|
+
Field,
|
|
16
|
+
validator,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from explicit.contrib.domain.model import (
|
|
20
|
+
uuid_identifier,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from dataclasses import dataclass # noqa
|
|
26
|
+
else:
|
|
27
|
+
from pydantic.dataclasses import dataclass # noqa
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DeadLetterNotFound(Exception): # noqa: N818
|
|
31
|
+
"""Необработанное сообщение не найдено."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, *args) -> None:
|
|
34
|
+
super().__init__('Необработанное сообщение не найдено', *args)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Attempt:
|
|
39
|
+
"""Попытка обработки сообщения."""
|
|
40
|
+
|
|
41
|
+
failed_at: datetime
|
|
42
|
+
error_message: str
|
|
43
|
+
traceback: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class DeadLetter:
|
|
48
|
+
"""Недоставленное/необработанное сообщение."""
|
|
49
|
+
|
|
50
|
+
id: uuid.UUID = uuid_identifier()
|
|
51
|
+
raw_message_value: bytes = Field(title='Содержимое сообщения')
|
|
52
|
+
raw_message_key: Union[bytes, None] = Field(title='Ключ сообщения', default=None)
|
|
53
|
+
topic: str = Field(title='Топик сообщения', max_length=256)
|
|
54
|
+
attempts: tuple[Attempt, ...] = Field(default_factory=tuple, title='Попытки обработки сообщения')
|
|
55
|
+
processed_at: Optional[datetime] = Field(default=None, title='Время успешной обработки сообщения')
|
|
56
|
+
|
|
57
|
+
@validator('attempts', always=True)
|
|
58
|
+
@classmethod
|
|
59
|
+
def _validate_attempts(cls, value):
|
|
60
|
+
if not value:
|
|
61
|
+
raise ValueError('Должна быть хотя бы одна попытка обработки')
|
|
62
|
+
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
def register_attempt(self, attempt: Attempt) -> None:
|
|
66
|
+
"""Добавить информацию о новой попытке обработки."""
|
|
67
|
+
self.attempts = (*self.attempts, attempt)
|
|
68
|
+
|
|
69
|
+
def mark_as_processed(self) -> None:
|
|
70
|
+
"""Отметить сообщение как успешно обработанное."""
|
|
71
|
+
self.processed_at = datetime.now(tz=timezone.utc)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Сервисы предметной области для работы с DLQ."""
|
|
2
|
+
|
|
3
|
+
from typing import (
|
|
4
|
+
Optional,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
from explicit.unit_of_work import (
|
|
8
|
+
AbstractUnitOfWork,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from .factories import (
|
|
12
|
+
DeadLetterDTO,
|
|
13
|
+
factory,
|
|
14
|
+
)
|
|
15
|
+
from .model import (
|
|
16
|
+
DeadLetter,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register_dead_letter(
|
|
21
|
+
raw_message_value: bytes,
|
|
22
|
+
raw_message_key: Optional[bytes],
|
|
23
|
+
topic: str,
|
|
24
|
+
exception: Exception,
|
|
25
|
+
uow: 'AbstractUnitOfWork',
|
|
26
|
+
) -> DeadLetter:
|
|
27
|
+
"""Зарегистрировать сообщение в очереди необработанных сообщений."""
|
|
28
|
+
dead_letter = uow.dead_letters.add( # type: ignore[attr-defined]
|
|
29
|
+
factory.create_from_exception(raw_message_value, raw_message_key, topic, exception)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return dead_letter
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def register_attempt(
|
|
36
|
+
data: DeadLetterDTO,
|
|
37
|
+
exception: Exception,
|
|
38
|
+
uow: 'AbstractUnitOfWork',
|
|
39
|
+
) -> DeadLetter:
|
|
40
|
+
"""Добавить информацию о новой попытке обработки."""
|
|
41
|
+
dead_letter = uow.dead_letters.get_object_by_id(data.id) # type: ignore[attr-defined]
|
|
42
|
+
|
|
43
|
+
dead_letter.register_attempt(factory.create_attempt_from_exception(exception))
|
|
44
|
+
|
|
45
|
+
return uow.dead_letters.update(dead_letter) # type: ignore[attr-defined]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def mark_as_processed(
|
|
49
|
+
data: DeadLetterDTO,
|
|
50
|
+
uow: 'AbstractUnitOfWork',
|
|
51
|
+
) -> DeadLetter:
|
|
52
|
+
"""Отметить сообщение как успешно обработанное."""
|
|
53
|
+
dead_letter = uow.dead_letters.get_object_by_id(data.id) # type: ignore[attr-defined]
|
|
54
|
+
dead_letter.mark_as_processed()
|
|
55
|
+
|
|
56
|
+
return uow.dead_letters.update(dead_letter) # type: ignore[attr-defined]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def update_raw_message(data, uow: 'AbstractUnitOfWork') -> DeadLetter:
|
|
60
|
+
"""Редактировать содержимое сообщения в DLQ."""
|
|
61
|
+
dead_letter = uow.dead_letters.get_object_by_id(data.id) # type: ignore[attr-defined]
|
|
62
|
+
dead_letter.raw_message_value = data.raw_message_value
|
|
63
|
+
|
|
64
|
+
return uow.dead_letters.update(dead_letter) # type: ignore[attr-defined]
|
|
File without changes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Инфраструктурные компоненты DQL."""
|
|
2
|
+
|
|
3
|
+
from contextlib import (
|
|
4
|
+
AbstractContextManager,
|
|
5
|
+
)
|
|
6
|
+
from typing import (
|
|
7
|
+
Optional,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from explicit.dlq.domain.commands import (
|
|
11
|
+
RegisterDeadLetter,
|
|
12
|
+
)
|
|
13
|
+
from explicit.messagebus import (
|
|
14
|
+
MessageBus,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RegisterInDLQOnFailure(AbstractContextManager):
|
|
19
|
+
"""Контекстный менеджер для обработки ошибок при обработке сообщений.
|
|
20
|
+
|
|
21
|
+
Перехватывает исключения при обработке сообщения и регистрирует такие сообщения в DLQ.
|
|
22
|
+
|
|
23
|
+
Пример использования:
|
|
24
|
+
|
|
25
|
+
.. code-block:: python
|
|
26
|
+
for raw_message_value in adapter.subscribe(*registered_topics):
|
|
27
|
+
with RegisterInDLQOnFailure(
|
|
28
|
+
bus=bus,
|
|
29
|
+
topic=raw_message_value.topic(),
|
|
30
|
+
raw_message_value=raw_message_value.value(),
|
|
31
|
+
raw_message_key=raw_message_value.raw_message_key(),
|
|
32
|
+
):
|
|
33
|
+
message = json.loads(raw_message_value.value())
|
|
34
|
+
if event := event_registry.resolve(Message(...)):
|
|
35
|
+
bus.handle(event)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
bus: 'MessageBus',
|
|
41
|
+
topic: str,
|
|
42
|
+
raw_message_value: bytes,
|
|
43
|
+
raw_message_key: Optional[bytes] = None,
|
|
44
|
+
):
|
|
45
|
+
self._bus = bus
|
|
46
|
+
self._topic = topic
|
|
47
|
+
self._raw_message_value = raw_message_value
|
|
48
|
+
self._raw_message_key = raw_message_key
|
|
49
|
+
|
|
50
|
+
def __enter__(self):
|
|
51
|
+
"""Вход в менеджер контекста."""
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
55
|
+
"""Зарегистрировать сообщение в DLQ при возникновении исключения."""
|
|
56
|
+
ret = False
|
|
57
|
+
|
|
58
|
+
if exc_val is not None:
|
|
59
|
+
command = RegisterDeadLetter(
|
|
60
|
+
topic=self._topic,
|
|
61
|
+
raw_message_value=self._raw_message_value,
|
|
62
|
+
raw_message_key=self._raw_message_key,
|
|
63
|
+
exception=exc_val,
|
|
64
|
+
)
|
|
65
|
+
try:
|
|
66
|
+
self._bus.handle(command)
|
|
67
|
+
except Exception as registration_exc:
|
|
68
|
+
raise registration_exc from exc_val
|
|
69
|
+
|
|
70
|
+
ret = True
|
|
71
|
+
|
|
72
|
+
return ret
|
explicit/dlq/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Обработчики команд DQL."""
|
|
2
|
+
|
|
3
|
+
from typing import (
|
|
4
|
+
TYPE_CHECKING,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
from explicit.dlq.domain import (
|
|
8
|
+
commands,
|
|
9
|
+
factories,
|
|
10
|
+
model,
|
|
11
|
+
services,
|
|
12
|
+
)
|
|
13
|
+
from explicit.domain.validation.exceptions import (
|
|
14
|
+
DomainValidationError,
|
|
15
|
+
init_messages_dict,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from explicit.dlq import (
|
|
21
|
+
types,
|
|
22
|
+
)
|
|
23
|
+
from explicit.messagebus import (
|
|
24
|
+
MessageBus,
|
|
25
|
+
)
|
|
26
|
+
from explicit.unit_of_work import (
|
|
27
|
+
AbstractUnitOfWork,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DLQHandlers:
|
|
32
|
+
"""Обработчики команд DLQ."""
|
|
33
|
+
|
|
34
|
+
__slots__ = ('_bus', '_retry_strategy', '_message_dispatcher')
|
|
35
|
+
|
|
36
|
+
_bus: 'MessageBus'
|
|
37
|
+
_message_dispatcher: 'types.DLQMessageDispatcher'
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
bus: 'MessageBus',
|
|
42
|
+
message_dispatcher: 'types.DLQMessageDispatcher',
|
|
43
|
+
):
|
|
44
|
+
self._bus = bus
|
|
45
|
+
self._message_dispatcher = message_dispatcher
|
|
46
|
+
|
|
47
|
+
def register_dead_letter(self, command: commands.RegisterDeadLetter, uow: 'AbstractUnitOfWork') -> model.DeadLetter:
|
|
48
|
+
"""Зарегистрировать сообщение, которое не удалось обработать."""
|
|
49
|
+
with uow.wrap():
|
|
50
|
+
return services.register_dead_letter(
|
|
51
|
+
raw_message_value=command.raw_message_value,
|
|
52
|
+
topic=command.topic,
|
|
53
|
+
raw_message_key=command.raw_message_key,
|
|
54
|
+
exception=command.exception,
|
|
55
|
+
uow=uow,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def process_dead_letter(self, command: commands.ProcessDeadLetter, uow: 'AbstractUnitOfWork') -> model.DeadLetter:
|
|
59
|
+
"""Выполнить обработку сообщения."""
|
|
60
|
+
with uow.wrap():
|
|
61
|
+
errors = init_messages_dict()
|
|
62
|
+
|
|
63
|
+
data = factories.DeadLetterDTO(id=command.id)
|
|
64
|
+
try:
|
|
65
|
+
dead_letter = uow.dead_letters.get_object_by_id(data.id) # type: ignore[attr-defined]
|
|
66
|
+
except model.DeadLetterNotFound as dlnf:
|
|
67
|
+
errors['id'].append(str(dlnf))
|
|
68
|
+
|
|
69
|
+
if errors:
|
|
70
|
+
raise DomainValidationError(errors)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
self._message_dispatcher(dead_letter.raw_message_value, dead_letter.topic)
|
|
74
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
75
|
+
# регистрируем неудачную попытку обработки
|
|
76
|
+
result = services.register_attempt(
|
|
77
|
+
data=data,
|
|
78
|
+
exception=e,
|
|
79
|
+
uow=uow,
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
# регистрируем успешную обработку
|
|
83
|
+
result = services.mark_as_processed(factories.DeadLetterDTO(id=dead_letter.id), uow)
|
|
84
|
+
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
def update_raw_message_value(
|
|
88
|
+
self, command: commands.UpdateDeadLetterRawMessage, uow: 'AbstractUnitOfWork'
|
|
89
|
+
) -> model.DeadLetter:
|
|
90
|
+
"""Редактировать содержимое сообщения в DLQ."""
|
|
91
|
+
with uow.wrap():
|
|
92
|
+
data = factories.DeadLetterDTO(id=command.id, raw_message_value=command.raw_message_value)
|
|
93
|
+
dead_letter = services.update_raw_message(data=data, uow=uow)
|
|
94
|
+
|
|
95
|
+
return dead_letter
|
explicit/dlq/types.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Типы для работы с DLQ."""
|
|
2
|
+
|
|
3
|
+
from typing import (
|
|
4
|
+
Protocol,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DLQMessageDispatcher(Protocol):
|
|
9
|
+
"""Протокол обработчика сообщений DLQ.
|
|
10
|
+
|
|
11
|
+
Получает сырое сообщение и топик, трансформирует его в событие предметной области и направляет в шину на обработку.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __call__(self, raw_message_value: bytes, topic: str) -> None:
|
|
15
|
+
"""Обработать сообщение."""
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: explicit-python-dlq
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Набор компонентов реализующих DQL для систем на базе explicit
|
|
5
|
+
Author-email: "АО \"БАРС Груп\"" <education_dev@bars-open.ru>
|
|
6
|
+
Classifier: Intended Audience :: Developers
|
|
7
|
+
Classifier: Environment :: Web Environment
|
|
8
|
+
Classifier: Natural Language :: Russian
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: explicit-python<3,>=2.2.1
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: freezegun; extra == "dev"
|
|
20
|
+
Provides-Extra: django
|
|
21
|
+
Requires-Dist: explicit-python-django<2.0,>=1.0.2; extra == "django"
|
|
22
|
+
Provides-Extra: rest
|
|
23
|
+
Requires-Dist: djangorestframework>=3.13.0; extra == "rest"
|
|
24
|
+
Requires-Dist: django_filter>=23.3; extra == "rest"
|
|
25
|
+
|
|
26
|
+
# Набор компонентов реализующих очередь необработанных сообщений (Dead Letter Queue).
|
|
27
|
+
|
|
28
|
+
## Пример подключения
|
|
29
|
+
testapp/dlq/apps.py:
|
|
30
|
+
```python
|
|
31
|
+
from django.apps import AppConfig as AppConfigBase
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AppConfig(AppConfigBase):
|
|
35
|
+
name = __package__
|
|
36
|
+
|
|
37
|
+
def ready(self):
|
|
38
|
+
self._configure_dlq()
|
|
39
|
+
|
|
40
|
+
def _configure_dlq(self):
|
|
41
|
+
from testapp.core import bus
|
|
42
|
+
from explicit.dlq.config import DLQConfig, configure_dlq
|
|
43
|
+
# реализация репозитория специфичная для используемого слоя хранения данных
|
|
44
|
+
from testapp.dlq.adapters.db import Repository
|
|
45
|
+
|
|
46
|
+
# реализация обработчика сообщений, специфичного для приложения
|
|
47
|
+
from testapp.dlq.services.dispatch import dispatch_message
|
|
48
|
+
|
|
49
|
+
config = DLQConfig(
|
|
50
|
+
bus=bus,
|
|
51
|
+
repository=Repository(),
|
|
52
|
+
message_dispatcher=dispatch_message
|
|
53
|
+
)
|
|
54
|
+
# регистрация репозитория и регистрация обработчиков команд
|
|
55
|
+
configure_dlq(config)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
testapp/dlq/services/dispatch.py:
|
|
59
|
+
```python
|
|
60
|
+
import json
|
|
61
|
+
from explicit.contrib.messagebus.event_registry import Message
|
|
62
|
+
|
|
63
|
+
from testapp.core import bus
|
|
64
|
+
from testapp.core import event_registry
|
|
65
|
+
|
|
66
|
+
def dispatch_message(raw_message_value: bytes, topic: str):
|
|
67
|
+
"""Преобразует сообщение в событие и отправляет его в шину."""
|
|
68
|
+
message = json.loads(raw_message_value)
|
|
69
|
+
|
|
70
|
+
if event := event_registry.resolve(
|
|
71
|
+
Message(
|
|
72
|
+
topic=topic,
|
|
73
|
+
type=message.get('type'),
|
|
74
|
+
body=message
|
|
75
|
+
)
|
|
76
|
+
):
|
|
77
|
+
bus.handle(event)
|
|
78
|
+
|
|
79
|
+
````
|
|
80
|
+
|
|
81
|
+
testapp/entrypoints/kafka.py:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
def bootstrap() -> None:
|
|
85
|
+
from explicit.dlq.infrastructure.handlers import RegisterInDLQOnFailure
|
|
86
|
+
|
|
87
|
+
from testapp.core import bus
|
|
88
|
+
|
|
89
|
+
from testapp.dlq.services.dispatch import dispatch_message
|
|
90
|
+
topics = ('test.foo.topic',)
|
|
91
|
+
|
|
92
|
+
for raw_message in adapter.subscribe(*topics):
|
|
93
|
+
with RegisterInDLQOnFailure(
|
|
94
|
+
bus=bus,
|
|
95
|
+
topic=raw_message.topic(),
|
|
96
|
+
raw_message_value=raw_message.value(),
|
|
97
|
+
raw_message_key=raw_message.key
|
|
98
|
+
):
|
|
99
|
+
dispatch_message(raw_message.value(), raw_message.topic())
|
|
100
|
+
```
|
|
101
|
+
## Готовые компоненты (contrib)
|
|
102
|
+
|
|
103
|
+
В пакете реализованы готовые к использованию компоненты:
|
|
104
|
+
* Реализация абстрактного [хранилища сообщений](./src/explicit/dlq/contrib/django/README.md) на базе Django ORM
|
|
105
|
+
* [REST API](./src/explicit/dlq/contrib/drf/README.md) для работы с хранилищем сообщений. Предназначен для совместной работы с реализацией хранилища сообщений django ORM.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
explicit/dlq/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
2
|
+
explicit/dlq/config.py,sha256=zpZrXqpIWPNzr6TBqvZ5Gs2tDwRVh0U43E1nIRGCm-E,1488
|
|
3
|
+
explicit/dlq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
explicit/dlq/types.py,sha256=aVqdsdKZ-U_5TD3dpLtzksyL3jVedIhJnu7IihW3vh4,544
|
|
5
|
+
explicit/dlq/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
explicit/dlq/adapters/db.py,sha256=c0j_dU3IRjm-7rx3S7nqE61vKjGsH8TYXiChbUQJoIU,346
|
|
7
|
+
explicit/dlq/contrib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
explicit/dlq/contrib/django/README.md,sha256=kpTW0GzYP8QV5ld7UI0ly-TWNbm3pbV7HWbdTpOGYAs,1290
|
|
9
|
+
explicit/dlq/contrib/django/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
explicit/dlq/contrib/django/apps.py,sha256=WDloauYls-krHbJOp_brpS8WifMyK8O59eUTSQDZROU,263
|
|
11
|
+
explicit/dlq/contrib/django/models.py,sha256=Zh9MTkkXaL1Pt5kwh9IFYnE29HXrd6QJxYWClxtyz6o,1772
|
|
12
|
+
explicit/dlq/contrib/django/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
explicit/dlq/contrib/django/adapters/db.py,sha256=6ta3IRLknO_J5vwvF1mJWVZqCziqmy7cfbhiiM8cjDk,3309
|
|
14
|
+
explicit/dlq/contrib/django/migrations/0001_initial.py,sha256=sDjCDZl1RvR_oOhBvftbW3LvRf6FbuLmRF_eQ-dauhM,2133
|
|
15
|
+
explicit/dlq/contrib/django/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
explicit/dlq/contrib/drf/README.md,sha256=9eQlgFEWIwrFhrWvLVL8_Br_-3wU2kAoLxHoBUY5PS8,748
|
|
17
|
+
explicit/dlq/contrib/drf/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
explicit/dlq/contrib/drf/serializers.py,sha256=zNASKGtXbw5unWqMJgDaqITwtVAsW54liJsXoK4FRBU,1774
|
|
19
|
+
explicit/dlq/contrib/drf/views.py,sha256=xBRUSzBbZ10PL8bHktZLosR-Tw-Q9jgN1n76oQmqLa4,2099
|
|
20
|
+
explicit/dlq/domain/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
explicit/dlq/domain/commands.py,sha256=v94uHQvGd-rX08_Paq9FEC-RSmsBaZMS9qxu6dHfzTo,803
|
|
22
|
+
explicit/dlq/domain/factories.py,sha256=5tNLTTggBA4JGm66wtiSjGrUGvvdh9ZZB6DXp5ax0pY,2270
|
|
23
|
+
explicit/dlq/domain/model.py,sha256=GBiDRGk4wpDZQqawZwNEtpHwS25YL_idvvkFNpajFvA,2291
|
|
24
|
+
explicit/dlq/domain/services.py,sha256=2HUZb3aL3ZAH01_lpvRcGLffVjdmiB0VV-ZxX5m8x60,2051
|
|
25
|
+
explicit/dlq/infrastructure/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
|
+
explicit/dlq/infrastructure/handlers.py,sha256=_1ZP8XtxkKgtDvLFZHVePHMkwtz1vhzNQJv2611pgfQ,2324
|
|
27
|
+
explicit/dlq/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
28
|
+
explicit/dlq/services/handlers.py,sha256=U6y4ElqXi1VocS3SE3SvwK69MMApAXILJes-xDbk_Ps,3232
|
|
29
|
+
explicit_python_dlq-1.0.0.dist-info/METADATA,sha256=QhLrBWGXg6ZTS92CcW6Frpa581Sf7FyAT9n3am5MtiY,4069
|
|
30
|
+
explicit_python_dlq-1.0.0.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
|
|
31
|
+
explicit_python_dlq-1.0.0.dist-info/top_level.txt,sha256=tt6T8l4Yji4ww87qZQcD4CbcwTIHy7NAPmU7QAfMcpY,9
|
|
32
|
+
explicit_python_dlq-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
explicit
|