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.
@@ -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,12 @@
1
+ """Конфигурация приложения."""
2
+
3
+ from django.apps import (
4
+ AppConfig as AppConfigBase,
5
+ )
6
+
7
+
8
+ class AppConfig(AppConfigBase):
9
+ """Конфигурация приложения."""
10
+
11
+ name = __package__
12
+ label = 'explicit_dlq_django'
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (77.0.3)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ explicit