nlbone 0.6.19__py3-none-any.whl → 0.7.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.
- nlbone/adapters/db/postgres/__init__.py +1 -1
- nlbone/adapters/db/postgres/base.py +2 -1
- nlbone/adapters/db/postgres/query_builder.py +1 -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 +14 -5
- nlbone/container.py +1 -8
- nlbone/core/application/bus.py +169 -0
- nlbone/core/application/di.py +128 -0
- nlbone/core/application/registry.py +129 -0
- 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 +116 -0
- nlbone/core/ports/uow.py +20 -1
- nlbone/interfaces/api/additional_filed/field_registry.py +2 -0
- nlbone/interfaces/cli/crypto.py +22 -0
- nlbone/interfaces/cli/init_db.py +39 -2
- nlbone/interfaces/cli/main.py +4 -0
- nlbone/interfaces/cli/ticket.py +29 -0
- nlbone/interfaces/jobs/dispatch_outbox.py +3 -2
- nlbone/utils/crypto.py +32 -0
- nlbone/utils/normalize_mobile.py +33 -0
- {nlbone-0.6.19.dist-info → nlbone-0.7.0.dist-info}/METADATA +4 -2
- {nlbone-0.6.19.dist-info → nlbone-0.7.0.dist-info}/RECORD +38 -31
- nlbone/adapters/repositories/outbox_repo.py +0 -20
- nlbone/core/application/command_bus.py +0 -25
- 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.19.dist-info → nlbone-0.7.0.dist-info}/WHEEL +0 -0
- {nlbone-0.6.19.dist-info → nlbone-0.7.0.dist-info}/entry_points.txt +0 -0
- {nlbone-0.6.19.dist-info → nlbone-0.7.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from typing import AsyncIterator, Iterable, Iterator, Optional
|
|
8
|
+
|
|
9
|
+
from nlbone.adapters.outbox.outbox_repo import AsyncOutboxRepository, OutboxRepository
|
|
10
|
+
from nlbone.core.domain.models import Outbox
|
|
11
|
+
from nlbone.core.ports import UnitOfWork
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def outbox_stream(
|
|
15
|
+
repo: AsyncOutboxRepository,
|
|
16
|
+
*,
|
|
17
|
+
batch_size: int = 100,
|
|
18
|
+
idle_sleep: float = 1.0,
|
|
19
|
+
stop_event: Optional[asyncio.Event] = None,
|
|
20
|
+
) -> AsyncIterator[Outbox]:
|
|
21
|
+
"""
|
|
22
|
+
Yields Outbox one-by-one. If none available, waits (idle_sleep) and tries again.
|
|
23
|
+
Designed to run forever until stop_event is set.
|
|
24
|
+
"""
|
|
25
|
+
while True:
|
|
26
|
+
if stop_event and stop_event.is_set():
|
|
27
|
+
return
|
|
28
|
+
batch: list[Outbox] = await repo.claim_batch(limit=batch_size)
|
|
29
|
+
if not batch:
|
|
30
|
+
await asyncio.sleep(idle_sleep)
|
|
31
|
+
continue
|
|
32
|
+
for msg in batch:
|
|
33
|
+
yield msg
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@asynccontextmanager
|
|
37
|
+
async def process_message(
|
|
38
|
+
repo: AsyncOutboxRepository,
|
|
39
|
+
msg: Outbox,
|
|
40
|
+
*,
|
|
41
|
+
backoff: timedelta = timedelta(seconds=30),
|
|
42
|
+
):
|
|
43
|
+
"""
|
|
44
|
+
Usage:
|
|
45
|
+
async with process_message(repo, msg):
|
|
46
|
+
... do work ...
|
|
47
|
+
On success -> mark_published
|
|
48
|
+
On exception -> mark_failed with backoff
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
yield msg
|
|
52
|
+
except Exception as e: # noqa: BLE001
|
|
53
|
+
await repo.mark_failed(msg.id, str(e), backoff=backoff)
|
|
54
|
+
raise
|
|
55
|
+
else:
|
|
56
|
+
await repo.mark_published([msg.id])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def process_batch(
|
|
60
|
+
repo: AsyncOutboxRepository,
|
|
61
|
+
messages: Iterable[Outbox],
|
|
62
|
+
*,
|
|
63
|
+
backoff: timedelta = timedelta(seconds=30),
|
|
64
|
+
concurrency: int = 1,
|
|
65
|
+
handler=None,
|
|
66
|
+
):
|
|
67
|
+
"""
|
|
68
|
+
Optional helper: run a handler concurrently on a batch.
|
|
69
|
+
handler: async callable(msg) -> None/… ; ack/nack handled via context manager.
|
|
70
|
+
"""
|
|
71
|
+
sem = asyncio.Semaphore(concurrency)
|
|
72
|
+
|
|
73
|
+
async def _run(m: Outbox):
|
|
74
|
+
async with sem:
|
|
75
|
+
async with process_message(repo, m, backoff=backoff):
|
|
76
|
+
if handler:
|
|
77
|
+
await handler(m)
|
|
78
|
+
|
|
79
|
+
await asyncio.gather(*(_run(m) for m in messages))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def outbox_stream_sync(
|
|
83
|
+
repo: OutboxRepository,
|
|
84
|
+
*,
|
|
85
|
+
topics : list[str] = None,
|
|
86
|
+
batch_size: int = 100,
|
|
87
|
+
idle_sleep: float = 1.0,
|
|
88
|
+
stop_flag: Optional[callable] = None,
|
|
89
|
+
) -> Iterator[Outbox]:
|
|
90
|
+
while True:
|
|
91
|
+
if stop_flag and stop_flag():
|
|
92
|
+
return
|
|
93
|
+
batch = repo.claim_batch(limit=batch_size, topics=topics)
|
|
94
|
+
if not batch:
|
|
95
|
+
time.sleep(idle_sleep)
|
|
96
|
+
continue
|
|
97
|
+
for msg in batch:
|
|
98
|
+
yield msg
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@contextmanager
|
|
102
|
+
def process_message_sync(uow: UnitOfWork, msg: Outbox, *, backoff: timedelta = timedelta(seconds=30)):
|
|
103
|
+
try:
|
|
104
|
+
yield msg
|
|
105
|
+
except Exception as e:
|
|
106
|
+
uow.rollback()
|
|
107
|
+
msg.mark_failed(str(e), backoff=backoff)
|
|
108
|
+
uow.commit()
|
|
109
|
+
raise
|
|
110
|
+
else:
|
|
111
|
+
msg.mark_published()
|
|
112
|
+
uow.commit()
|
|
@@ -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_env_fild() -> 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() not in {"prod", "production", "stage", "staging"}
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
class Settings(BaseSettings):
|
|
@@ -96,6 +96,15 @@ class Settings(BaseSettings):
|
|
|
96
96
|
# ---------------------------
|
|
97
97
|
PRICING_SERVICE_URL: AnyHttpUrl = Field(default="https://pricing.numberland.ir/v1")
|
|
98
98
|
|
|
99
|
+
# ---------------------------
|
|
100
|
+
# Crypto
|
|
101
|
+
# ---------------------------
|
|
102
|
+
FERNET_KEY: str = Field(default="")
|
|
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
|
+
|
|
99
108
|
model_config = SettingsConfigDict(
|
|
100
109
|
env_prefix="",
|
|
101
110
|
env_file=None,
|
|
@@ -105,9 +114,9 @@ class Settings(BaseSettings):
|
|
|
105
114
|
|
|
106
115
|
@classmethod
|
|
107
116
|
def load(cls, env_file: str | None = None) -> "Settings":
|
|
108
|
-
if
|
|
109
|
-
return cls()
|
|
110
|
-
return cls(
|
|
117
|
+
if read_from_env_fild():
|
|
118
|
+
return cls(_env_file=env_file or _guess_env_file())
|
|
119
|
+
return cls()
|
|
111
120
|
|
|
112
121
|
|
|
113
122
|
@lru_cache(maxsize=4)
|
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)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import traceback
|
|
2
|
+
from typing import Any, Callable, Coroutine, List, Optional, Protocol
|
|
3
|
+
|
|
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
|
+
from nlbone.interfaces.api.middleware.access_log import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SyncMiddleware(Protocol):
|
|
12
|
+
def __call__(self, message: Message, next_: Callable[[Message], None]) -> None: # noqa: D401
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AsyncMiddleware(Protocol):
|
|
17
|
+
async def __call__(self, message: Message, next_: Callable[[Message], Coroutine[Any, Any, None]]) -> None: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SyncMessageBus:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
uow: UnitOfWork,
|
|
24
|
+
registry: HandlerRegistry,
|
|
25
|
+
middlewares: Optional[List[SyncMiddleware]] = None,
|
|
26
|
+
outbox: Optional[OutboxPublisher] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.uow = uow
|
|
29
|
+
self.registry = registry
|
|
30
|
+
self.middlewares = middlewares or []
|
|
31
|
+
self.outbox = outbox
|
|
32
|
+
self._queue: List[Message] = []
|
|
33
|
+
|
|
34
|
+
def handle(self, message: Message) -> None:
|
|
35
|
+
self._queue.append(message)
|
|
36
|
+
while self._queue:
|
|
37
|
+
msg = self._queue.pop(0)
|
|
38
|
+
self._dispatch_with_pipeline(msg)
|
|
39
|
+
|
|
40
|
+
# pipeline that wraps dispatch
|
|
41
|
+
def _dispatch_with_pipeline(self, message: Message) -> None:
|
|
42
|
+
def terminal(m: Message) -> None:
|
|
43
|
+
self._dispatch(m)
|
|
44
|
+
|
|
45
|
+
# build chain right-to-left
|
|
46
|
+
next_callable = terminal
|
|
47
|
+
for mw in reversed(self.middlewares):
|
|
48
|
+
current_mw = mw
|
|
49
|
+
|
|
50
|
+
def make_next(nxt: Callable[[Message], None]): # closure helper
|
|
51
|
+
def _mw_call(m: Message) -> None:
|
|
52
|
+
return current_mw(m, nxt)
|
|
53
|
+
|
|
54
|
+
return _mw_call
|
|
55
|
+
|
|
56
|
+
next_callable = make_next(next_callable)
|
|
57
|
+
next_callable(message)
|
|
58
|
+
|
|
59
|
+
def _dispatch(self, message: Message) -> None:
|
|
60
|
+
if isinstance(message, DomainEvent):
|
|
61
|
+
self._handle_event(message)
|
|
62
|
+
elif isinstance(message, Command):
|
|
63
|
+
self._handle_command(message)
|
|
64
|
+
else:
|
|
65
|
+
raise TypeError(f"Unknown message type: {type(message)!r}")
|
|
66
|
+
|
|
67
|
+
def _handle_event(self, event: DomainEvent) -> None:
|
|
68
|
+
handlers = self.registry.for_event(type(event))
|
|
69
|
+
for handler in handlers:
|
|
70
|
+
try:
|
|
71
|
+
logger.debug("handling event %s with %s", event, handler)
|
|
72
|
+
produced = handler(event)
|
|
73
|
+
if produced:
|
|
74
|
+
self._queue.extend(produced)
|
|
75
|
+
self._queue.extend(self.uow.collect_new_events())
|
|
76
|
+
except Exception: # noqa: BLE001
|
|
77
|
+
logger.exception("Exception handling event %s\n%s", event, traceback.format_exc())
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
def _handle_command(self, command: Command) -> None:
|
|
81
|
+
handler = self.registry.for_command(type(command))
|
|
82
|
+
try:
|
|
83
|
+
logger.debug("handling command %s with %s", command, handler)
|
|
84
|
+
produced = handler(command)
|
|
85
|
+
if produced:
|
|
86
|
+
self._queue.extend(produced)
|
|
87
|
+
# commit (and gather/emit domain events)
|
|
88
|
+
self._queue.extend(self.uow.collect_new_events())
|
|
89
|
+
if self.outbox:
|
|
90
|
+
self.outbox.publish(self._queue) # best-effort; in real systems use a DB-backed outbox
|
|
91
|
+
except Exception: # noqa: BLE001
|
|
92
|
+
logger.exception("Exception handling command %s\n%s", command, traceback.format_exc())
|
|
93
|
+
raise
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ==========================
|
|
97
|
+
# MessageBus (async)
|
|
98
|
+
# ==========================
|
|
99
|
+
class AsyncMessageBus:
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
uow: AsyncUnitOfWork,
|
|
103
|
+
registry: AsyncHandlerRegistry,
|
|
104
|
+
middlewares: Optional[List[AsyncMiddleware]] = None,
|
|
105
|
+
outbox: Optional[AsyncOutboxPublisher] = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
self.uow = uow
|
|
108
|
+
self.registry = registry
|
|
109
|
+
self.middlewares = middlewares or []
|
|
110
|
+
self.outbox = outbox
|
|
111
|
+
self._queue: List[Message] = []
|
|
112
|
+
|
|
113
|
+
async def handle(self, message: Message) -> None:
|
|
114
|
+
self._queue.append(message)
|
|
115
|
+
while self._queue:
|
|
116
|
+
msg = self._queue.pop(0)
|
|
117
|
+
await self._dispatch_with_pipeline(msg)
|
|
118
|
+
|
|
119
|
+
async def _dispatch_with_pipeline(self, message: Message) -> None:
|
|
120
|
+
async def terminal(m: Message) -> None:
|
|
121
|
+
await self._dispatch(m)
|
|
122
|
+
|
|
123
|
+
next_callable = terminal
|
|
124
|
+
for mw in reversed(self.middlewares):
|
|
125
|
+
current_mw = mw
|
|
126
|
+
|
|
127
|
+
def make_next(nxt: Callable[[Message], Coroutine[Any, Any, None]]):
|
|
128
|
+
async def _mw_call(m: Message) -> None:
|
|
129
|
+
return await current_mw(m, nxt)
|
|
130
|
+
|
|
131
|
+
return _mw_call
|
|
132
|
+
|
|
133
|
+
next_callable = make_next(next_callable)
|
|
134
|
+
await next_callable(message)
|
|
135
|
+
|
|
136
|
+
async def _dispatch(self, message: Message) -> None:
|
|
137
|
+
if isinstance(message, DomainEvent):
|
|
138
|
+
await self._handle_event(message)
|
|
139
|
+
elif isinstance(message, Command):
|
|
140
|
+
await self._handle_command(message)
|
|
141
|
+
else:
|
|
142
|
+
raise TypeError(f"Unknown message type: {type(message)!r}")
|
|
143
|
+
|
|
144
|
+
async def _handle_event(self, event: DomainEvent) -> None:
|
|
145
|
+
handlers = self.registry.for_event(type(event))
|
|
146
|
+
for handler in handlers:
|
|
147
|
+
try:
|
|
148
|
+
logger.debug("handling event %s with %s", event, handler)
|
|
149
|
+
produced = await handler(event)
|
|
150
|
+
if produced:
|
|
151
|
+
self._queue.extend(produced)
|
|
152
|
+
self._queue.extend(await self.uow.collect_new_events())
|
|
153
|
+
except Exception: # noqa: BLE001
|
|
154
|
+
logger.exception("Exception handling event %s\n%s", event, traceback.format_exc())
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
async def _handle_command(self, command: Command) -> None:
|
|
158
|
+
handler = self.registry.for_command(type(command))
|
|
159
|
+
try:
|
|
160
|
+
logger.debug("handling command %s with %s", command, handler)
|
|
161
|
+
produced = await handler(command)
|
|
162
|
+
if produced:
|
|
163
|
+
self._queue.extend(produced)
|
|
164
|
+
self._queue.extend(await self.uow.collect_new_events())
|
|
165
|
+
if self.outbox:
|
|
166
|
+
await self.outbox.publish(self._queue)
|
|
167
|
+
except Exception: # noqa: BLE001
|
|
168
|
+
logger.exception("Exception handling command %s\n%s", command, traceback.format_exc())
|
|
169
|
+
raise
|