nlbone 0.6.20__py3-none-any.whl → 0.7.1__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.
- nlbone/adapters/db/postgres/__init__.py +1 -1
- nlbone/adapters/db/postgres/base.py +2 -1
- nlbone/adapters/db/postgres/repository.py +254 -29
- nlbone/adapters/db/postgres/uow.py +36 -1
- nlbone/adapters/messaging/__init__.py +1 -1
- nlbone/adapters/messaging/event_bus.py +97 -17
- nlbone/adapters/messaging/rabbitmq.py +45 -0
- nlbone/adapters/outbox/__init__.py +1 -0
- nlbone/adapters/outbox/outbox_consumer.py +112 -0
- nlbone/adapters/outbox/outbox_repo.py +191 -0
- nlbone/adapters/ticketing/client.py +39 -0
- nlbone/config/settings.py +7 -3
- nlbone/container.py +1 -8
- nlbone/core/application/bus.py +7 -7
- nlbone/core/application/di.py +43 -14
- nlbone/core/application/registry.py +12 -6
- nlbone/core/domain/base.py +30 -9
- nlbone/core/domain/models.py +46 -3
- nlbone/core/ports/__init__.py +0 -2
- nlbone/core/ports/event_bus.py +23 -6
- nlbone/core/ports/outbox.py +73 -0
- nlbone/core/ports/repository.py +10 -9
- nlbone/core/ports/uow.py +20 -1
- nlbone/interfaces/api/additional_filed/field_registry.py +2 -0
- nlbone/interfaces/cli/init_db.py +39 -2
- nlbone/interfaces/cli/main.py +2 -0
- nlbone/interfaces/cli/ticket.py +29 -0
- nlbone/interfaces/jobs/dispatch_outbox.py +2 -2
- nlbone/utils/crypto.py +7 -4
- {nlbone-0.6.20.dist-info → nlbone-0.7.1.dist-info}/METADATA +3 -2
- {nlbone-0.6.20.dist-info → nlbone-0.7.1.dist-info}/RECORD +35 -34
- nlbone/adapters/repositories/outbox_repo.py +0 -18
- nlbone/core/application/events.py +0 -20
- nlbone/core/application/services.py +0 -0
- nlbone/core/domain/events.py +0 -0
- nlbone/core/ports/messaging.py +0 -0
- nlbone/core/ports/repo.py +0 -19
- /nlbone/adapters/{messaging/redis.py → ticketing/__init__.py} +0 -0
- {nlbone-0.6.20.dist-info → nlbone-0.7.1.dist-info}/WHEEL +0 -0
- {nlbone-0.6.20.dist-info → nlbone-0.7.1.dist-info}/entry_points.txt +0 -0
- {nlbone-0.6.20.dist-info → nlbone-0.7.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import (
|
|
7
|
+
delete,
|
|
8
|
+
select,
|
|
9
|
+
update,
|
|
10
|
+
)
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
from sqlalchemy.orm import Session
|
|
13
|
+
|
|
14
|
+
from nlbone.core.domain.models import Outbox, OutboxStatus
|
|
15
|
+
from nlbone.core.ports.outbox import AsyncOutboxRepository, OutboxRepository
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SQLAlchemyOutboxRepository(OutboxRepository):
|
|
19
|
+
def __init__(self, session: Session) -> None:
|
|
20
|
+
self.session = session
|
|
21
|
+
|
|
22
|
+
def _now(self) -> datetime:
|
|
23
|
+
return datetime.now(timezone.utc)
|
|
24
|
+
|
|
25
|
+
def enqueue(self, topic: str, payload: Dict[str, Any], *, headers=None, key=None, available_at=None) -> Outbox:
|
|
26
|
+
row = Outbox(
|
|
27
|
+
topic=topic,
|
|
28
|
+
payload=payload,
|
|
29
|
+
headers=headers or {},
|
|
30
|
+
key=key,
|
|
31
|
+
available_at=available_at or self._now(),
|
|
32
|
+
)
|
|
33
|
+
self.session.add(row)
|
|
34
|
+
# Do not commit here; caller's transaction should commit
|
|
35
|
+
self.session.flush()
|
|
36
|
+
return row
|
|
37
|
+
|
|
38
|
+
def enqueue_many(
|
|
39
|
+
self, items: Iterable[Tuple[str, Dict[str, Any]]], *, headers=None, available_at=None
|
|
40
|
+
) -> List[Outbox]:
|
|
41
|
+
rows = [
|
|
42
|
+
Outbox(topic=t, payload=p, headers=headers or {}, available_at=available_at or self._now())
|
|
43
|
+
for t, p in items
|
|
44
|
+
]
|
|
45
|
+
self.session.add_all(rows)
|
|
46
|
+
self.session.flush()
|
|
47
|
+
return [r for r in rows]
|
|
48
|
+
|
|
49
|
+
def claim_batch(self, *, topics: list[str] = None, limit: int = 100, now: Optional[datetime] = None) -> List[
|
|
50
|
+
Outbox]:
|
|
51
|
+
now = now or self._now()
|
|
52
|
+
# Select candidates eligible to process
|
|
53
|
+
stmt = (
|
|
54
|
+
select(Outbox)
|
|
55
|
+
.where(
|
|
56
|
+
Outbox.topic.in_(topics),
|
|
57
|
+
Outbox.status.in_([OutboxStatus.PENDING, OutboxStatus.FAILED]),
|
|
58
|
+
Outbox.available_at <= now,
|
|
59
|
+
(Outbox.next_attempt_at.is_(None)) | (Outbox.next_attempt_at <= now),
|
|
60
|
+
)
|
|
61
|
+
.order_by(Outbox.id)
|
|
62
|
+
.limit(limit)
|
|
63
|
+
.with_for_update(skip_locked=True)
|
|
64
|
+
)
|
|
65
|
+
rows = self.session.execute(stmt).scalars().all()
|
|
66
|
+
if not rows:
|
|
67
|
+
return []
|
|
68
|
+
# Mark as PROCESSING and increment attempts
|
|
69
|
+
ids = [r.id for r in rows]
|
|
70
|
+
self.session.execute(
|
|
71
|
+
update(Outbox)
|
|
72
|
+
.where(Outbox.id.in_(ids))
|
|
73
|
+
.values(status=OutboxStatus.PROCESSING, attempts=Outbox.attempts + 1)
|
|
74
|
+
)
|
|
75
|
+
self.session.flush()
|
|
76
|
+
return [r for r in rows]
|
|
77
|
+
|
|
78
|
+
def mark_published(self, ids: Iterable[int]) -> None:
|
|
79
|
+
ids = list(ids)
|
|
80
|
+
if not ids:
|
|
81
|
+
return
|
|
82
|
+
self.session.execute(
|
|
83
|
+
update(Outbox)
|
|
84
|
+
.where(Outbox.id.in_(ids))
|
|
85
|
+
.values(status=OutboxStatus.PUBLISHED, last_error=None, next_attempt_at=None)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def mark_failed(self, id: int, error: str, *, backoff: timedelta = timedelta(seconds=30)) -> None:
|
|
89
|
+
self.session.execute(
|
|
90
|
+
update(Outbox)
|
|
91
|
+
.where(Outbox.id == id)
|
|
92
|
+
.values(
|
|
93
|
+
status=OutboxStatus.FAILED,
|
|
94
|
+
last_error=error,
|
|
95
|
+
next_attempt_at=self._now() + backoff,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def delete_older_than(self, *, before: datetime, status: Optional[OutboxStatus] = None) -> int:
|
|
100
|
+
conds = [Outbox.created_at < before]
|
|
101
|
+
if status is not None:
|
|
102
|
+
conds.append(Outbox.status == status)
|
|
103
|
+
stmt = delete(Outbox).where(*conds)
|
|
104
|
+
res = self.session.execute(stmt)
|
|
105
|
+
return res.rowcount or 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class SQLAlchemyAsyncOutboxRepository(AsyncOutboxRepository):
|
|
109
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
110
|
+
self.session = session
|
|
111
|
+
|
|
112
|
+
def _now(self) -> datetime:
|
|
113
|
+
return datetime.now(timezone.utc)
|
|
114
|
+
|
|
115
|
+
async def enqueue(
|
|
116
|
+
self, topic: str, payload: Dict[str, Any], *, headers=None, key=None, available_at=None
|
|
117
|
+
) -> Outbox:
|
|
118
|
+
row = Outbox(
|
|
119
|
+
topic=topic,
|
|
120
|
+
payload=payload,
|
|
121
|
+
headers=headers or {},
|
|
122
|
+
key=key,
|
|
123
|
+
available_at=available_at or self._now(),
|
|
124
|
+
)
|
|
125
|
+
self.session.add(row)
|
|
126
|
+
await self.session.flush()
|
|
127
|
+
return row
|
|
128
|
+
|
|
129
|
+
async def enqueue_many(
|
|
130
|
+
self, items: Iterable[Tuple[str, Dict[str, Any]]], *, headers=None, available_at=None
|
|
131
|
+
) -> List[Outbox]:
|
|
132
|
+
rows = [
|
|
133
|
+
Outbox(topic=t, payload=p, headers=headers or {}, available_at=available_at or self._now())
|
|
134
|
+
for t, p in items
|
|
135
|
+
]
|
|
136
|
+
self.session.add_all(rows)
|
|
137
|
+
await self.session.flush()
|
|
138
|
+
return [r for r in rows]
|
|
139
|
+
|
|
140
|
+
async def claim_batch(self, *, limit: int = 100, now: Optional[datetime] = None) -> List[Outbox]:
|
|
141
|
+
now = now or self._now()
|
|
142
|
+
stmt = (
|
|
143
|
+
select(Outbox)
|
|
144
|
+
.where(
|
|
145
|
+
Outbox.status.in_([OutboxStatus.PENDING, OutboxStatus.FAILED]),
|
|
146
|
+
Outbox.available_at <= now,
|
|
147
|
+
(Outbox.next_attempt_at.is_(None)) | (Outbox.next_attempt_at <= now),
|
|
148
|
+
)
|
|
149
|
+
.order_by(Outbox.id)
|
|
150
|
+
.limit(limit)
|
|
151
|
+
.with_for_update(skip_locked=True)
|
|
152
|
+
)
|
|
153
|
+
rows = (await self.session.execute(stmt)).scalars().all()
|
|
154
|
+
if not rows:
|
|
155
|
+
return []
|
|
156
|
+
ids = [r.id for r in rows]
|
|
157
|
+
await self.session.execute(
|
|
158
|
+
update(Outbox)
|
|
159
|
+
.where(Outbox.id.in_(ids))
|
|
160
|
+
.values(status=OutboxStatus.PROCESSING, attempts=Outbox.attempts + 1)
|
|
161
|
+
)
|
|
162
|
+
await self.session.flush()
|
|
163
|
+
return [r.to_domain() for r in rows]
|
|
164
|
+
|
|
165
|
+
async def mark_published(self, ids: Iterable[int]) -> None:
|
|
166
|
+
ids = list(ids)
|
|
167
|
+
if not ids:
|
|
168
|
+
return
|
|
169
|
+
await self.session.execute(
|
|
170
|
+
update(Outbox)
|
|
171
|
+
.where(Outbox.id.in_(ids))
|
|
172
|
+
.values(status=OutboxStatus.PUBLISHED, last_error=None, next_attempt_at=None)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
async def mark_failed(self, id: int, error: str, *, backoff: timedelta = timedelta(seconds=30)) -> None:
|
|
176
|
+
await self.session.execute(
|
|
177
|
+
update(Outbox)
|
|
178
|
+
.where(Outbox.id == id)
|
|
179
|
+
.values(
|
|
180
|
+
status=OutboxStatus.FAILED,
|
|
181
|
+
last_error=error,
|
|
182
|
+
next_attempt_at=self._now() + backoff,
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
async def delete_older_than(self, *, before: datetime, status: Optional[OutboxStatus] = None) -> int:
|
|
187
|
+
conds = [Outbox.created_at < before]
|
|
188
|
+
if status is not None:
|
|
189
|
+
conds.append(Outbox.status == status)
|
|
190
|
+
res = await self.session.execute(delete(Outbox).where(*conds))
|
|
191
|
+
return res.rowcount or 0
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from typing import Any, Dict, Optional
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from nlbone.adapters.messaging.rabbitmq import RabbitMQEventBus
|
|
6
|
+
from nlbone.config.settings import get_settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CreateTicketIn(BaseModel):
|
|
10
|
+
assignee_id: str
|
|
11
|
+
category_id: int
|
|
12
|
+
channel: str
|
|
13
|
+
direction: str
|
|
14
|
+
entity_id: str
|
|
15
|
+
entity_type: str
|
|
16
|
+
message: str
|
|
17
|
+
priority: str
|
|
18
|
+
product_id: int
|
|
19
|
+
status: str
|
|
20
|
+
title: str
|
|
21
|
+
user_id: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TicketingClient:
|
|
25
|
+
def __init__(self):
|
|
26
|
+
settings = get_settings()
|
|
27
|
+
self._bus = RabbitMQEventBus(settings.RABBITMQ_URL)
|
|
28
|
+
self._exchange = settings.RABBITMQ_TICKETING_EXCHANGE
|
|
29
|
+
self._rk_create_v1 = settings.RABBITMQ_TICKETING_ROUTING_KEY_CREATE_V1
|
|
30
|
+
|
|
31
|
+
async def create_ticket(self, payload: CreateTicketIn, created_by_id: int, *,
|
|
32
|
+
override_exchange: Optional[str] = None,
|
|
33
|
+
override_routing_key: Optional[str] = None) -> None:
|
|
34
|
+
exchange = override_exchange or self._exchange
|
|
35
|
+
routing_key = override_routing_key or self._rk_create_v1
|
|
36
|
+
payload = payload.model_dump()
|
|
37
|
+
payload.update({'created_by_id': created_by_id})
|
|
38
|
+
print(payload)
|
|
39
|
+
await self._bus.publish(exchange=exchange, routing_key=routing_key, payload=payload)
|
nlbone/config/settings.py
CHANGED
|
@@ -26,11 +26,11 @@ def _guess_env_file() -> str | None:
|
|
|
26
26
|
raise Exception("Failed to guess env file path!") from e
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def
|
|
29
|
+
def read_from_os_env() -> bool:
|
|
30
30
|
raw = os.getenv("NLBONE_ENV") or os.getenv("ENV") or os.getenv("ENVIRONMENT")
|
|
31
31
|
if not raw:
|
|
32
32
|
return False
|
|
33
|
-
return raw.strip().lower() in {"prod", "production"}
|
|
33
|
+
return raw.strip().lower() in {"prod", "production", "stage", "staging"}
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
class Settings(BaseSettings):
|
|
@@ -101,6 +101,10 @@ class Settings(BaseSettings):
|
|
|
101
101
|
# ---------------------------
|
|
102
102
|
FERNET_KEY: str = Field(default="")
|
|
103
103
|
|
|
104
|
+
RABBITMQ_URL: str = Field(default="", description="amqp(s)://user:pass@host:5672/vhost")
|
|
105
|
+
RABBITMQ_TICKETING_EXCHANGE: str = "crm_stage.ticket"
|
|
106
|
+
RABBITMQ_TICKETING_ROUTING_KEY_CREATE_V1: str = "crm_stage.ticket.create.v1"
|
|
107
|
+
|
|
104
108
|
model_config = SettingsConfigDict(
|
|
105
109
|
env_prefix="",
|
|
106
110
|
env_file=None,
|
|
@@ -110,7 +114,7 @@ class Settings(BaseSettings):
|
|
|
110
114
|
|
|
111
115
|
@classmethod
|
|
112
116
|
def load(cls, env_file: str | None = None) -> "Settings":
|
|
113
|
-
if
|
|
117
|
+
if read_from_os_env():
|
|
114
118
|
return cls()
|
|
115
119
|
return cls(_env_file=env_file or _guess_env_file())
|
|
116
120
|
|
nlbone/container.py
CHANGED
|
@@ -10,13 +10,10 @@ from nlbone.adapters.auth.token_provider import ClientTokenProvider
|
|
|
10
10
|
from nlbone.adapters.cache.async_redis import AsyncRedisCache
|
|
11
11
|
from nlbone.adapters.cache.memory import InMemoryCache
|
|
12
12
|
from nlbone.adapters.cache.redis import RedisCache
|
|
13
|
-
from nlbone.adapters.db.postgres import AsyncSqlAlchemyUnitOfWork, SqlAlchemyUnitOfWork
|
|
14
13
|
from nlbone.adapters.db.postgres.engine import get_async_session_factory, get_sync_session_factory
|
|
15
14
|
from nlbone.adapters.http_clients import PricingService
|
|
16
15
|
from nlbone.adapters.http_clients.uploadchi import UploadchiClient
|
|
17
16
|
from nlbone.adapters.http_clients.uploadchi.uploadchi_async import UploadchiAsyncClient
|
|
18
|
-
from nlbone.adapters.messaging import InMemoryEventBus
|
|
19
|
-
from nlbone.core.ports import EventBusPort
|
|
20
17
|
from nlbone.core.ports.cache import AsyncCachePort, CachePort
|
|
21
18
|
from nlbone.core.ports.files import AsyncFileServicePort, FileServicePort
|
|
22
19
|
|
|
@@ -27,12 +24,8 @@ class Container(containers.DeclarativeContainer):
|
|
|
27
24
|
sync_session_factory = providers.Singleton(get_sync_session_factory)
|
|
28
25
|
async_session_factory = providers.Singleton(get_async_session_factory)
|
|
29
26
|
|
|
30
|
-
# --- UoW ---
|
|
31
|
-
uow = providers.Factory(SqlAlchemyUnitOfWork, session_factory=sync_session_factory)
|
|
32
|
-
async_uow = providers.Factory(AsyncSqlAlchemyUnitOfWork, session_factory=async_session_factory)
|
|
33
|
-
|
|
34
27
|
# --- Event bus ---
|
|
35
|
-
event_bus: providers.Singleton[EventBusPort] = providers.Singleton(InMemoryEventBus)
|
|
28
|
+
# event_bus: providers.Singleton[EventBusPort] = providers.Singleton(InMemoryEventBus)
|
|
36
29
|
|
|
37
30
|
# --- Services ---
|
|
38
31
|
auth: providers.Singleton[KeycloakAuthService] = providers.Singleton(KeycloakAuthService)
|
nlbone/core/application/bus.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import traceback
|
|
2
|
-
from typing import
|
|
2
|
+
from typing import Any, Callable, Coroutine, List, Optional, Protocol
|
|
3
3
|
|
|
4
|
-
from nlbone.core.application.registry import
|
|
5
|
-
from nlbone.core.domain.base import Command, DomainEvent
|
|
6
|
-
from nlbone.core.ports import
|
|
7
|
-
from nlbone.core.ports.event_bus import
|
|
4
|
+
from nlbone.core.application.registry import AsyncHandlerRegistry, HandlerRegistry
|
|
5
|
+
from nlbone.core.domain.base import Command, DomainEvent, Message
|
|
6
|
+
from nlbone.core.ports import AsyncUnitOfWork, UnitOfWork
|
|
7
|
+
from nlbone.core.ports.event_bus import AsyncOutboxPublisher, OutboxPublisher
|
|
8
8
|
from nlbone.interfaces.api.middleware.access_log import logger
|
|
9
9
|
|
|
10
10
|
|
|
@@ -149,7 +149,7 @@ class AsyncMessageBus:
|
|
|
149
149
|
produced = await handler(event)
|
|
150
150
|
if produced:
|
|
151
151
|
self._queue.extend(produced)
|
|
152
|
-
self._queue.extend(self.uow.collect_new_events())
|
|
152
|
+
self._queue.extend(await self.uow.collect_new_events())
|
|
153
153
|
except Exception: # noqa: BLE001
|
|
154
154
|
logger.exception("Exception handling event %s\n%s", event, traceback.format_exc())
|
|
155
155
|
continue
|
|
@@ -161,7 +161,7 @@ class AsyncMessageBus:
|
|
|
161
161
|
produced = await handler(command)
|
|
162
162
|
if produced:
|
|
163
163
|
self._queue.extend(produced)
|
|
164
|
-
self._queue.extend(self.uow.collect_new_events())
|
|
164
|
+
self._queue.extend(await self.uow.collect_new_events())
|
|
165
165
|
if self.outbox:
|
|
166
166
|
await self.outbox.publish(self._queue)
|
|
167
167
|
except Exception: # noqa: BLE001
|
nlbone/core/application/di.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
import inspect
|
|
3
|
-
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any, Callable, Dict, Optional, Type, get_args, get_origin, get_type_hints
|
|
6
|
+
|
|
4
7
|
|
|
5
8
|
class TypeContainer:
|
|
6
9
|
"""Tiny type-based DI: register_instance(T, obj) / register_factory(T, () -> obj)."""
|
|
10
|
+
|
|
7
11
|
def __init__(self) -> None:
|
|
8
12
|
self._instances: Dict[Type[Any], Any] = {}
|
|
9
13
|
self._factories: Dict[Type[Any], Callable[[], Any]] = {}
|
|
@@ -62,6 +66,7 @@ class TypeContainer:
|
|
|
62
66
|
return None
|
|
63
67
|
raise LookupError(f"No provider for {types}")
|
|
64
68
|
|
|
69
|
+
|
|
65
70
|
def _mro_distance(sub: Type[Any], sup: Type[Any]) -> int:
|
|
66
71
|
try:
|
|
67
72
|
return sub.mro().index(sup)
|
|
@@ -69,31 +74,55 @@ def _mro_distance(sub: Type[Any], sup: Type[Any]) -> int:
|
|
|
69
74
|
return 10**6
|
|
70
75
|
|
|
71
76
|
|
|
77
|
+
def _type_hints(obj):
|
|
78
|
+
mod = sys.modules.get(getattr(obj, "__module__", "__name__"))
|
|
79
|
+
return get_type_hints(obj, globalns=vars(mod) if mod else None)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _build_kwargs(
|
|
83
|
+
sig: inspect.Signature,
|
|
84
|
+
hints: Dict[str, Any],
|
|
85
|
+
container: TypeContainer,
|
|
86
|
+
*,
|
|
87
|
+
skip_params: int = 0,
|
|
88
|
+
) -> Dict[str, Any]:
|
|
89
|
+
kwargs: Dict[str, Any] = {}
|
|
90
|
+
for name, param in list(sig.parameters.items())[skip_params:]:
|
|
91
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
92
|
+
continue
|
|
93
|
+
ann = hints.get(name, param.annotation)
|
|
94
|
+
if param.default is not inspect._empty:
|
|
95
|
+
kwargs[name] = param.default
|
|
96
|
+
else:
|
|
97
|
+
kwargs[name] = container.resolve(ann)
|
|
98
|
+
return kwargs
|
|
99
|
+
|
|
100
|
+
|
|
72
101
|
def bind_callable(fn: Callable[..., Any], c: TypeContainer) -> Callable[..., Any]:
|
|
73
102
|
sig = inspect.signature(fn)
|
|
74
|
-
hints =
|
|
103
|
+
hints = _type_hints(fn)
|
|
104
|
+
|
|
75
105
|
def wrapper(message: Any):
|
|
76
|
-
kwargs =
|
|
77
|
-
for name, param in list(sig.parameters.items())[1:]:
|
|
78
|
-
ann = hints.get(name, param.annotation)
|
|
79
|
-
kwargs[name] = c.resolve(ann)
|
|
106
|
+
kwargs = _build_kwargs(sig, hints, c, skip_params=1)
|
|
80
107
|
return fn(message, **kwargs)
|
|
108
|
+
|
|
81
109
|
return wrapper
|
|
82
110
|
|
|
83
111
|
|
|
84
112
|
def bind_handler(handler: Any, c: TypeContainer) -> Callable[..., Any]:
|
|
85
113
|
if inspect.isclass(handler):
|
|
86
|
-
init = handler.__init__
|
|
114
|
+
init = handler.__init__
|
|
87
115
|
sig = inspect.signature(init)
|
|
88
|
-
hints =
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
instance = handler(**kwargs) # type: ignore
|
|
116
|
+
hints = _type_hints(init)
|
|
117
|
+
|
|
118
|
+
kwargs = _build_kwargs(sig, hints, c, skip_params=1)
|
|
119
|
+
|
|
120
|
+
instance = handler(**kwargs) # type: ignore[arg-type]
|
|
94
121
|
if not callable(instance):
|
|
95
122
|
raise TypeError(f"{handler!r} must implement __call__")
|
|
96
123
|
return instance
|
|
97
|
-
|
|
124
|
+
|
|
125
|
+
if callable(handler):
|
|
98
126
|
return bind_callable(handler, c)
|
|
127
|
+
|
|
99
128
|
raise TypeError(f"Unsupported handler: {handler!r}")
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
from typing import
|
|
1
|
+
from typing import Any, Callable, Coroutine, Dict, Iterable, List, Optional, Type, TypeVar
|
|
2
2
|
|
|
3
3
|
from nlbone.core.application.di import TypeContainer, bind_handler
|
|
4
|
-
from nlbone.core.domain.base import DomainEvent,
|
|
5
|
-
from nlbone.core.ports.event_bus import Message
|
|
4
|
+
from nlbone.core.domain.base import Command, DomainEvent, Message
|
|
6
5
|
|
|
7
6
|
TMsg = TypeVar("TMsg", bound=Message)
|
|
8
7
|
SyncHandler = Callable[[TMsg], Optional[Iterable[Message]]]
|
|
@@ -88,9 +87,16 @@ def handles_command_async(cmd_type: Type[Command], registry: AsyncHandlerRegistr
|
|
|
88
87
|
|
|
89
88
|
|
|
90
89
|
def make_sync_decorators(registry: HandlerRegistry, container: TypeContainer):
|
|
91
|
-
def handles_command(cmd_type
|
|
92
|
-
def deco(h
|
|
93
|
-
|
|
90
|
+
def handles_command(cmd_type):
|
|
91
|
+
def deco(h):
|
|
92
|
+
instance_ref = {"inst": None}
|
|
93
|
+
|
|
94
|
+
def lazy_handler(message):
|
|
95
|
+
if instance_ref["inst"] is None:
|
|
96
|
+
instance_ref["inst"] = bind_handler(h, container)
|
|
97
|
+
return instance_ref["inst"](message)
|
|
98
|
+
|
|
99
|
+
registry.register_command(cmd_type, lazy_handler)
|
|
94
100
|
return h
|
|
95
101
|
|
|
96
102
|
return deco
|
nlbone/core/domain/base.py
CHANGED
|
@@ -2,7 +2,11 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
from datetime import datetime, timezone
|
|
5
|
+
from enum import Enum
|
|
5
6
|
from typing import Any, Generic, List, TypeVar
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
6
10
|
|
|
7
11
|
TId = TypeVar("TId")
|
|
8
12
|
|
|
@@ -11,17 +15,28 @@ class DomainError(Exception):
|
|
|
11
15
|
"""Base domain exception."""
|
|
12
16
|
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
class
|
|
18
|
+
|
|
19
|
+
class Message(BaseModel):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DomainEvent(Message):
|
|
16
25
|
"""Immutable domain event."""
|
|
17
26
|
|
|
18
27
|
occurred_at: datetime = datetime.now(timezone.utc)
|
|
28
|
+
event_id: str = uuid4()
|
|
19
29
|
|
|
20
30
|
@property
|
|
21
31
|
def name(self):
|
|
22
32
|
return self.__class__.__name__
|
|
23
33
|
|
|
24
34
|
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Command:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
25
40
|
class ValueObject:
|
|
26
41
|
"""Base for value objects (immutable in practice)."""
|
|
27
42
|
|
|
@@ -39,13 +54,19 @@ class Entity(Generic[TId]):
|
|
|
39
54
|
class AggregateRoot(Entity[TId]):
|
|
40
55
|
"""Aggregate root with domain event collection."""
|
|
41
56
|
|
|
42
|
-
def __init__(self
|
|
43
|
-
self.
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self._events: List[DomainEvent] = []
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def events(self) -> List[DomainEvent]:
|
|
62
|
+
return self._events
|
|
44
63
|
|
|
45
64
|
def _raise(self, event: DomainEvent) -> None:
|
|
46
|
-
self.
|
|
65
|
+
self._events.append(event)
|
|
66
|
+
|
|
67
|
+
def clear_events(self) -> None:
|
|
68
|
+
self._events.clear()
|
|
69
|
+
|
|
47
70
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
self._domain_events.clear()
|
|
51
|
-
return events
|
|
71
|
+
class BaseEnum(Enum):
|
|
72
|
+
pass
|
nlbone/core/domain/models.py
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
import enum
|
|
1
2
|
import uuid
|
|
2
|
-
from datetime import datetime
|
|
3
|
+
from datetime import datetime, timezone, timedelta
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
3
5
|
|
|
6
|
+
from sqlalchemy import JSON, DateTime, Index, Integer, String, Text
|
|
4
7
|
from sqlalchemy import JSON as SA_JSON
|
|
5
|
-
from sqlalchemy import
|
|
8
|
+
from sqlalchemy import Enum as SA_Enum
|
|
6
9
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
7
10
|
from sqlalchemy.sql import func
|
|
8
11
|
|
|
9
|
-
from nlbone.adapters.db import Base
|
|
12
|
+
from nlbone.adapters.db.postgres.base import Base
|
|
13
|
+
from nlbone.utils.time import now
|
|
10
14
|
|
|
11
15
|
try:
|
|
12
16
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
@@ -38,3 +42,42 @@ class AuditLog(Base):
|
|
|
38
42
|
Index("ix_audit_entity_entityid", "entity", "entity_id"),
|
|
39
43
|
Index("ix_audit_created_at", "created_at"),
|
|
40
44
|
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OutboxStatus(str, enum.Enum):
|
|
48
|
+
PENDING = "pending"
|
|
49
|
+
PROCESSING = "processing"
|
|
50
|
+
PUBLISHED = "published"
|
|
51
|
+
FAILED = "failed"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Outbox(Base):
|
|
55
|
+
__tablename__ = "outbox"
|
|
56
|
+
|
|
57
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
58
|
+
topic: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
|
59
|
+
payload: Mapped[Dict[str, Any]] = mapped_column(JSON, nullable=False)
|
|
60
|
+
headers: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
|
|
61
|
+
key: Mapped[Optional[str]] = mapped_column(String(200), nullable=True, index=True)
|
|
62
|
+
|
|
63
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
|
64
|
+
available_at: Mapped[datetime] = mapped_column(
|
|
65
|
+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
status: Mapped[OutboxStatus] = mapped_column(SA_Enum(OutboxStatus), default=OutboxStatus.PENDING, index=True)
|
|
69
|
+
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
|
70
|
+
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
71
|
+
next_attempt_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
|
72
|
+
|
|
73
|
+
def mark_failed(self, error: str, *, backoff: timedelta = timedelta(seconds=30)):
|
|
74
|
+
self.status = OutboxStatus.FAILED
|
|
75
|
+
self.last_error = error
|
|
76
|
+
self.next_attempt_at = now() + backoff
|
|
77
|
+
|
|
78
|
+
def mark_published(self):
|
|
79
|
+
self.status = OutboxStatus.PUBLISHED
|
|
80
|
+
self.next_attempt_at = None
|
|
81
|
+
|
|
82
|
+
def to_outbox_row(evt) -> Outbox:
|
|
83
|
+
return Outbox(topic=evt.topic, payload=evt.__dict__)
|
nlbone/core/ports/__init__.py
CHANGED
nlbone/core/ports/event_bus.py
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
|
-
from
|
|
1
|
+
from typing import Any, Awaitable, Callable, Iterable, Protocol, Type, runtime_checkable
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from nlbone.core.domain.base import DomainEvent, Message
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
EventHandler = Callable[[DomainEvent], Any] | Callable[[DomainEvent], Awaitable[Any]]
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
class
|
|
9
|
-
def
|
|
10
|
-
|
|
8
|
+
class EventBus(Protocol):
|
|
9
|
+
def subscribe(self, event_type: Type[DomainEvent] | str, handler: EventHandler) -> None: ...
|
|
10
|
+
|
|
11
|
+
def publish(self, event: DomainEvent) -> None: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EventPublisher(Protocol):
|
|
15
|
+
def publish(self, event: DomainEvent) -> None: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class OutboxPublisher(Protocol):
|
|
20
|
+
"""Optional: publish integration messages reliably after commit."""
|
|
21
|
+
|
|
22
|
+
def publish(self, messages: Iterable[Message]) -> None: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@runtime_checkable
|
|
26
|
+
class AsyncOutboxPublisher(Protocol):
|
|
27
|
+
async def publish(self, messages: Iterable[Message]) -> None: ...
|