datacurrent 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.
- datacurrent/__init__.py +157 -0
- datacurrent/_version.py +6 -0
- datacurrent/buffering/__init__.py +8 -0
- datacurrent/buffering/backpressure.py +47 -0
- datacurrent/buffering/queue.py +304 -0
- datacurrent/concurrency/__init__.py +8 -0
- datacurrent/concurrency/limits.py +106 -0
- datacurrent/concurrency/tasks.py +86 -0
- datacurrent/config.py +86 -0
- datacurrent/core/__init__.py +17 -0
- datacurrent/core/_util.py +42 -0
- datacurrent/core/lifecycle.py +81 -0
- datacurrent/core/operators.py +844 -0
- datacurrent/core/pipeline.py +64 -0
- datacurrent/core/sink.py +70 -0
- datacurrent/core/stream.py +566 -0
- datacurrent/events/__init__.py +9 -0
- datacurrent/events/bus.py +380 -0
- datacurrent/events/routing.py +59 -0
- datacurrent/events/subscriptions.py +75 -0
- datacurrent/exceptions.py +191 -0
- datacurrent/integrations/__init__.py +18 -0
- datacurrent/integrations/fastapi.py +76 -0
- datacurrent/integrations/starlette.py +75 -0
- datacurrent/observability/__init__.py +17 -0
- datacurrent/observability/logging.py +128 -0
- datacurrent/observability/metrics.py +127 -0
- datacurrent/py.typed +1 -0
- datacurrent/reliability/__init__.py +14 -0
- datacurrent/reliability/retry.py +134 -0
- datacurrent/reliability/timeout.py +39 -0
- datacurrent/transports/__init__.py +31 -0
- datacurrent/transports/_deps.py +23 -0
- datacurrent/transports/base.py +80 -0
- datacurrent/transports/http.py +211 -0
- datacurrent/transports/memory.py +171 -0
- datacurrent/transports/sse.py +374 -0
- datacurrent/transports/websocket.py +277 -0
- datacurrent-1.0.0.dist-info/METADATA +250 -0
- datacurrent-1.0.0.dist-info/RECORD +43 -0
- datacurrent-1.0.0.dist-info/WHEEL +5 -0
- datacurrent-1.0.0.dist-info/licenses/LICENSE +20 -0
- datacurrent-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Event processing: envelope, routing, subscriptions and the in-memory bus."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .bus import Event, EventBus
|
|
6
|
+
from .routing import matches
|
|
7
|
+
from .subscriptions import Subscription
|
|
8
|
+
|
|
9
|
+
__all__ = ["Event", "EventBus", "Subscription", "matches"]
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""A small, fast, in-process event bus.
|
|
2
|
+
|
|
3
|
+
Delivery is **at most once** and entirely in memory: there is no persistence, no
|
|
4
|
+
acknowledgement and no replay. If that is not enough, use a durable broker
|
|
5
|
+
adapter; DataCurrent never pretends an in-memory bus is durable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import AsyncIterator, Callable, Mapping
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from types import MappingProxyType
|
|
16
|
+
from typing import Any, Generic, TypeVar, overload
|
|
17
|
+
|
|
18
|
+
from ..buffering.backpressure import BufferPolicy
|
|
19
|
+
from ..config import DEFAULT_BUFFER_SIZE, OverflowPolicy, StreamConfig
|
|
20
|
+
from ..core._util import maybe_await
|
|
21
|
+
from ..core.stream import Stream
|
|
22
|
+
from ..exceptions import (
|
|
23
|
+
ConfigurationError,
|
|
24
|
+
QueueClosedError,
|
|
25
|
+
StreamClosedError,
|
|
26
|
+
)
|
|
27
|
+
from ..observability.logging import get_logger
|
|
28
|
+
from ..observability.metrics import StreamStats
|
|
29
|
+
from .routing import validate_pattern, validate_topic
|
|
30
|
+
from .subscriptions import Subscription
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
|
|
34
|
+
__all__ = ["Event", "EventBus"]
|
|
35
|
+
|
|
36
|
+
_log = get_logger("events")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class Event(Generic[T]):
|
|
41
|
+
"""An immutable event envelope."""
|
|
42
|
+
|
|
43
|
+
topic: str
|
|
44
|
+
data: T
|
|
45
|
+
id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
46
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
47
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
48
|
+
source: str | None = None
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
if not isinstance(self.topic, str) or not self.topic:
|
|
52
|
+
raise ConfigurationError("event topic must be a non-empty string")
|
|
53
|
+
object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata or {})))
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
return {
|
|
57
|
+
"id": self.id,
|
|
58
|
+
"topic": self.topic,
|
|
59
|
+
"data": self.data,
|
|
60
|
+
"timestamp": self.timestamp.isoformat(),
|
|
61
|
+
"metadata": dict(self.metadata),
|
|
62
|
+
"source": self.source,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class EventBus:
|
|
67
|
+
"""Fan-out events to handlers and streams.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
buffer_size: Per-subscriber queue capacity.
|
|
71
|
+
overflow: Overflow policy; the default ``BLOCK`` applies backpressure.
|
|
72
|
+
on_error: Optional ``callable(error, subscription)`` invoked when a
|
|
73
|
+
handler raises. Without it, errors are logged and isolated.
|
|
74
|
+
name: Optional bus name used in logs.
|
|
75
|
+
|
|
76
|
+
Example::
|
|
77
|
+
|
|
78
|
+
bus = EventBus()
|
|
79
|
+
|
|
80
|
+
@bus.subscribe("user.*")
|
|
81
|
+
async def on_user(event):
|
|
82
|
+
print(event.topic, event.data)
|
|
83
|
+
|
|
84
|
+
await bus.publish("user.created", {"user_id": 123})
|
|
85
|
+
await bus.aclose(drain=True)
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
buffer_size: int = DEFAULT_BUFFER_SIZE,
|
|
92
|
+
overflow: OverflowPolicy | str = OverflowPolicy.BLOCK,
|
|
93
|
+
on_error: Callable[[Exception, Subscription], Any] | None = None,
|
|
94
|
+
name: str | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
if on_error is not None and not callable(on_error):
|
|
97
|
+
raise TypeError("on_error must be callable or None")
|
|
98
|
+
self._policy = BufferPolicy(size=buffer_size, overflow=OverflowPolicy.coerce(overflow))
|
|
99
|
+
self._on_error = on_error
|
|
100
|
+
self._name = name
|
|
101
|
+
self._subscriptions: list[Subscription] = []
|
|
102
|
+
self._closed = False
|
|
103
|
+
self._published = 0
|
|
104
|
+
self._delivered = 0
|
|
105
|
+
self._failed = 0
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------ #
|
|
108
|
+
# Subscriptions
|
|
109
|
+
# ------------------------------------------------------------------ #
|
|
110
|
+
@overload
|
|
111
|
+
def subscribe( # pragma: no cover - typing overload
|
|
112
|
+
self,
|
|
113
|
+
topic: str,
|
|
114
|
+
handler: Callable[[Event[Any]], Any],
|
|
115
|
+
*,
|
|
116
|
+
buffer_size: int | None = None,
|
|
117
|
+
overflow: OverflowPolicy | str | None = None,
|
|
118
|
+
) -> Subscription: ...
|
|
119
|
+
|
|
120
|
+
@overload
|
|
121
|
+
def subscribe( # pragma: no cover - typing overload
|
|
122
|
+
self,
|
|
123
|
+
topic: str = "*",
|
|
124
|
+
handler: None = None,
|
|
125
|
+
*,
|
|
126
|
+
buffer_size: int | None = None,
|
|
127
|
+
overflow: OverflowPolicy | str | None = None,
|
|
128
|
+
) -> Callable[[Callable[[Event[Any]], Any]], Subscription]: ...
|
|
129
|
+
|
|
130
|
+
def subscribe(
|
|
131
|
+
self,
|
|
132
|
+
topic: str = "*",
|
|
133
|
+
handler: Callable[[Event[Any]], Any] | None = None,
|
|
134
|
+
*,
|
|
135
|
+
buffer_size: int | None = None,
|
|
136
|
+
overflow: OverflowPolicy | str | None = None,
|
|
137
|
+
) -> Subscription | Callable[[Callable[[Event[Any]], Any]], Subscription]:
|
|
138
|
+
"""Subscribe a handler. Also works as a decorator.
|
|
139
|
+
|
|
140
|
+
The handler receives an :class:`Event` and may be sync or async.
|
|
141
|
+
Handler errors are isolated per event and reported through ``on_error``.
|
|
142
|
+
"""
|
|
143
|
+
validate_pattern(topic)
|
|
144
|
+
if handler is None:
|
|
145
|
+
|
|
146
|
+
def decorator(func: Callable[[Event[Any]], Any]) -> Subscription:
|
|
147
|
+
return self._add_subscription(topic, func, buffer_size, overflow)
|
|
148
|
+
|
|
149
|
+
return decorator
|
|
150
|
+
if not callable(handler):
|
|
151
|
+
raise TypeError(f"handler must be callable, got {type(handler).__name__}")
|
|
152
|
+
return self._add_subscription(topic, handler, buffer_size, overflow)
|
|
153
|
+
|
|
154
|
+
def _add_subscription(
|
|
155
|
+
self,
|
|
156
|
+
topic: str,
|
|
157
|
+
handler: Callable[[Event[Any]], Any] | None,
|
|
158
|
+
buffer_size: int | None,
|
|
159
|
+
overflow: OverflowPolicy | str | None,
|
|
160
|
+
) -> Subscription:
|
|
161
|
+
if self._closed:
|
|
162
|
+
raise StreamClosedError(f"event bus {self._name!r} is closed")
|
|
163
|
+
resolved_overflow = OverflowPolicy.coerce(
|
|
164
|
+
self._policy.overflow if overflow is None else overflow
|
|
165
|
+
)
|
|
166
|
+
policy = BufferPolicy(
|
|
167
|
+
size=self._policy.size if buffer_size is None else buffer_size,
|
|
168
|
+
overflow=resolved_overflow,
|
|
169
|
+
)
|
|
170
|
+
queue = policy.build(name=f"eventbus:{topic}")
|
|
171
|
+
subscription = Subscription(topic=topic, queue=queue, handler=handler, bus=self)
|
|
172
|
+
self._subscriptions.append(subscription)
|
|
173
|
+
if handler is not None:
|
|
174
|
+
self._ensure_worker(subscription)
|
|
175
|
+
return subscription
|
|
176
|
+
|
|
177
|
+
def stream(
|
|
178
|
+
self,
|
|
179
|
+
topic: str = "*",
|
|
180
|
+
*,
|
|
181
|
+
buffer_size: int | None = None,
|
|
182
|
+
overflow: OverflowPolicy | str | None = None,
|
|
183
|
+
name: str | None = None,
|
|
184
|
+
) -> Stream[Event[Any]]:
|
|
185
|
+
"""Consume matching events as a single-consumer stream."""
|
|
186
|
+
subscription = self._add_subscription(topic, None, buffer_size, overflow)
|
|
187
|
+
stats = StreamStats(name=name or f"eventbus:{topic}")
|
|
188
|
+
config = StreamConfig(
|
|
189
|
+
name=name or f"eventbus:{topic}",
|
|
190
|
+
buffer_size=self._policy.size if buffer_size is None else buffer_size,
|
|
191
|
+
overflow=OverflowPolicy.coerce(self._policy.overflow if overflow is None else overflow),
|
|
192
|
+
observe=True,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
async def factory() -> AsyncIterator[Event[Any]]:
|
|
196
|
+
try:
|
|
197
|
+
async for event in subscription.queue:
|
|
198
|
+
stats.items_out += 1
|
|
199
|
+
yield event
|
|
200
|
+
finally:
|
|
201
|
+
await self.unsubscribe(subscription)
|
|
202
|
+
|
|
203
|
+
stream: Stream[Event[Any]] = Stream(factory, config=config, replayable=False)
|
|
204
|
+
stream._stats = stats
|
|
205
|
+
return stream
|
|
206
|
+
|
|
207
|
+
async def unsubscribe(self, subscription: Subscription) -> None:
|
|
208
|
+
"""Remove a subscription; safe to call more than once."""
|
|
209
|
+
if subscription in self._subscriptions:
|
|
210
|
+
self._subscriptions.remove(subscription)
|
|
211
|
+
await subscription.aclose()
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def subscriber_count(self) -> int:
|
|
215
|
+
return sum(1 for subscription in self._subscriptions if subscription.active)
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def subscriptions(self) -> tuple[Subscription, ...]:
|
|
219
|
+
return tuple(self._subscriptions)
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------------------------------ #
|
|
222
|
+
# Publishing
|
|
223
|
+
# ------------------------------------------------------------------ #
|
|
224
|
+
async def publish(
|
|
225
|
+
self,
|
|
226
|
+
topic: str,
|
|
227
|
+
data: Any = None,
|
|
228
|
+
*,
|
|
229
|
+
metadata: Mapping[str, Any] | None = None,
|
|
230
|
+
event_id: str | None = None,
|
|
231
|
+
source: str | None = None,
|
|
232
|
+
) -> int:
|
|
233
|
+
"""Publish ``data`` on ``topic``; returns the number of subscribers reached."""
|
|
234
|
+
event: Event[Any] = Event(
|
|
235
|
+
topic=validate_topic(topic),
|
|
236
|
+
data=data,
|
|
237
|
+
metadata=dict(metadata or {}),
|
|
238
|
+
id=event_id or uuid.uuid4().hex,
|
|
239
|
+
source=source,
|
|
240
|
+
)
|
|
241
|
+
return await self.publish_event(event)
|
|
242
|
+
|
|
243
|
+
async def publish_event(self, event: Event[Any]) -> int:
|
|
244
|
+
"""Publish a pre-built event."""
|
|
245
|
+
if self._closed:
|
|
246
|
+
raise StreamClosedError(f"event bus {self._name!r} is closed")
|
|
247
|
+
if not isinstance(event, Event):
|
|
248
|
+
raise TypeError(f"publish_event() expects an Event, got {type(event).__name__}")
|
|
249
|
+
self._published += 1
|
|
250
|
+
delivered = 0
|
|
251
|
+
for subscription in list(self._subscriptions):
|
|
252
|
+
if not subscription.active or not subscription.matches(event.topic):
|
|
253
|
+
continue
|
|
254
|
+
if subscription.handler is not None:
|
|
255
|
+
self._ensure_worker(subscription)
|
|
256
|
+
try:
|
|
257
|
+
stored = await subscription.queue.put(event)
|
|
258
|
+
except QueueClosedError:
|
|
259
|
+
subscription.active = False
|
|
260
|
+
continue
|
|
261
|
+
if stored:
|
|
262
|
+
delivered += 1
|
|
263
|
+
self._delivered += delivered
|
|
264
|
+
return delivered
|
|
265
|
+
|
|
266
|
+
def _ensure_worker(self, subscription: Subscription) -> None:
|
|
267
|
+
if subscription.task is not None and not subscription.task.done():
|
|
268
|
+
return
|
|
269
|
+
try:
|
|
270
|
+
loop = asyncio.get_running_loop()
|
|
271
|
+
except RuntimeError: # pragma: no cover - publish() always runs in a loop
|
|
272
|
+
return
|
|
273
|
+
subscription.task = loop.create_task(
|
|
274
|
+
self._dispatch(subscription),
|
|
275
|
+
name=f"datacurrent-eventbus-{subscription.topic}",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
async def _dispatch(self, subscription: Subscription) -> None:
|
|
279
|
+
handler = subscription.handler
|
|
280
|
+
if handler is None: # pragma: no cover - stream subscriptions have no worker
|
|
281
|
+
return
|
|
282
|
+
try:
|
|
283
|
+
async for event in subscription.queue:
|
|
284
|
+
subscription.in_flight += 1
|
|
285
|
+
try:
|
|
286
|
+
await maybe_await(handler(event))
|
|
287
|
+
subscription.delivered += 1
|
|
288
|
+
except asyncio.CancelledError:
|
|
289
|
+
raise
|
|
290
|
+
except Exception as error:
|
|
291
|
+
subscription.failed += 1
|
|
292
|
+
self._failed += 1
|
|
293
|
+
await self._report(error, subscription)
|
|
294
|
+
finally:
|
|
295
|
+
subscription.in_flight -= 1
|
|
296
|
+
except asyncio.CancelledError:
|
|
297
|
+
raise
|
|
298
|
+
except Exception as error: # pragma: no cover - queue surfaced a failure
|
|
299
|
+
await self._report(error, subscription)
|
|
300
|
+
|
|
301
|
+
async def _report(self, error: Exception, subscription: Subscription) -> None:
|
|
302
|
+
if self._on_error is not None:
|
|
303
|
+
try:
|
|
304
|
+
await maybe_await(self._on_error(error, subscription))
|
|
305
|
+
return
|
|
306
|
+
except asyncio.CancelledError:
|
|
307
|
+
raise
|
|
308
|
+
except Exception:
|
|
309
|
+
_log.exception("on_error handler failed")
|
|
310
|
+
return
|
|
311
|
+
_log.error(
|
|
312
|
+
"event handler failed topic=%s subscription=%r: %r",
|
|
313
|
+
subscription.topic,
|
|
314
|
+
subscription,
|
|
315
|
+
error,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# ------------------------------------------------------------------ #
|
|
319
|
+
# Lifecycle
|
|
320
|
+
# ------------------------------------------------------------------ #
|
|
321
|
+
async def aclose(
|
|
322
|
+
self,
|
|
323
|
+
*,
|
|
324
|
+
drain: bool = False,
|
|
325
|
+
timeout: float = 5.0, # noqa: ASYNC109 - drain deadline, not a single await
|
|
326
|
+
) -> None:
|
|
327
|
+
"""Close the bus.
|
|
328
|
+
|
|
329
|
+
With ``drain=True`` queued events are processed before shutting down.
|
|
330
|
+
"""
|
|
331
|
+
if self._closed:
|
|
332
|
+
return
|
|
333
|
+
if drain:
|
|
334
|
+
await self._drain(timeout)
|
|
335
|
+
self._closed = True
|
|
336
|
+
subscriptions = list(self._subscriptions)
|
|
337
|
+
self._subscriptions.clear()
|
|
338
|
+
for subscription in subscriptions:
|
|
339
|
+
await subscription.aclose()
|
|
340
|
+
|
|
341
|
+
async def _drain(self, timeout: float) -> None: # noqa: ASYNC109 - polling deadline
|
|
342
|
+
loop = asyncio.get_running_loop()
|
|
343
|
+
deadline = loop.time() + timeout
|
|
344
|
+
while True:
|
|
345
|
+
pending = [
|
|
346
|
+
subscription
|
|
347
|
+
for subscription in self._subscriptions
|
|
348
|
+
if subscription.active
|
|
349
|
+
and (subscription.queue.qsize() > 0 or subscription.in_flight > 0)
|
|
350
|
+
]
|
|
351
|
+
if not pending:
|
|
352
|
+
return
|
|
353
|
+
if loop.time() >= deadline:
|
|
354
|
+
_log.warning("event bus drain timed out with %d queued event(s)", len(pending))
|
|
355
|
+
return
|
|
356
|
+
await asyncio.sleep(0.005)
|
|
357
|
+
|
|
358
|
+
async def __aenter__(self) -> EventBus:
|
|
359
|
+
return self
|
|
360
|
+
|
|
361
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
362
|
+
await self.aclose(drain=True)
|
|
363
|
+
|
|
364
|
+
def stats(self) -> dict[str, Any]:
|
|
365
|
+
dropped = sum(subscription.queue.stats().dropped for subscription in self._subscriptions)
|
|
366
|
+
return {
|
|
367
|
+
"name": self._name,
|
|
368
|
+
"published": self._published,
|
|
369
|
+
"delivered": self._delivered,
|
|
370
|
+
"failed": self._failed,
|
|
371
|
+
"dropped": dropped,
|
|
372
|
+
"subscribers": self.subscriber_count,
|
|
373
|
+
"closed": self._closed,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
def __repr__(self) -> str:
|
|
377
|
+
return (
|
|
378
|
+
f"EventBus(name={self._name!r}, subscribers={self.subscriber_count}, "
|
|
379
|
+
f"published={self._published})"
|
|
380
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Topic matching for the event bus.
|
|
2
|
+
|
|
3
|
+
Topics are dot-separated strings such as ``user.created``. Patterns support
|
|
4
|
+
exact segments, ``*`` for exactly one segment and a trailing ``**`` (or ``#``)
|
|
5
|
+
for any remaining segments. ``*`` alone matches every topic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ..exceptions import ConfigurationError
|
|
11
|
+
|
|
12
|
+
__all__ = ["matches", "validate_pattern", "validate_topic"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _validate(value: str, *, name: str) -> str:
|
|
16
|
+
if not isinstance(value, str):
|
|
17
|
+
raise ConfigurationError(f"{name} must be a string, got {type(value).__name__}")
|
|
18
|
+
if not value or value.strip() != value:
|
|
19
|
+
raise ConfigurationError(f"{name} must be non-empty and have no surrounding whitespace")
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_topic(topic: str) -> str:
|
|
24
|
+
"""Validate an event topic."""
|
|
25
|
+
return _validate(topic, name="topic")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_pattern(pattern: str) -> str:
|
|
29
|
+
"""Validate a subscription pattern."""
|
|
30
|
+
return _validate(pattern, name="pattern")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def matches(pattern: str, topic: str) -> bool:
|
|
34
|
+
"""Return whether ``topic`` matches the subscription ``pattern``.
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
>>> matches("user.created", "user.created")
|
|
38
|
+
True
|
|
39
|
+
>>> matches("user.*", "user.created")
|
|
40
|
+
True
|
|
41
|
+
>>> matches("user.*", "user.created.v2")
|
|
42
|
+
False
|
|
43
|
+
>>> matches("user.**", "user.created.v2")
|
|
44
|
+
True
|
|
45
|
+
>>> matches("*", "anything.at.all")
|
|
46
|
+
True
|
|
47
|
+
"""
|
|
48
|
+
if pattern in ("*", "#", "**"):
|
|
49
|
+
return True
|
|
50
|
+
pattern_parts = pattern.split(".")
|
|
51
|
+
topic_parts = topic.split(".")
|
|
52
|
+
for index, part in enumerate(pattern_parts):
|
|
53
|
+
if part in ("**", "#"):
|
|
54
|
+
return index == len(pattern_parts) - 1
|
|
55
|
+
if index >= len(topic_parts):
|
|
56
|
+
return False
|
|
57
|
+
if part != "*" and part != topic_parts[index]:
|
|
58
|
+
return False
|
|
59
|
+
return len(pattern_parts) == len(topic_parts)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Subscriptions for the in-memory event bus."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from ..buffering.queue import BoundedQueue
|
|
11
|
+
from ..exceptions import QueueClosedError
|
|
12
|
+
from .routing import matches
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .bus import EventBus
|
|
16
|
+
|
|
17
|
+
__all__ = ["Subscription"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class Subscription:
|
|
22
|
+
"""A single subscriber: a topic pattern, a bounded queue and a handler.
|
|
23
|
+
|
|
24
|
+
``handler`` is ``None`` for stream subscriptions, where the application
|
|
25
|
+
consumes the queue directly through ``EventBus.stream()``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
topic: str
|
|
29
|
+
queue: BoundedQueue[Any]
|
|
30
|
+
handler: Callable[[Any], Any] | None
|
|
31
|
+
bus: EventBus
|
|
32
|
+
task: asyncio.Task[Any] | None = None
|
|
33
|
+
active: bool = True
|
|
34
|
+
delivered: int = 0
|
|
35
|
+
failed: int = 0
|
|
36
|
+
in_flight: int = 0
|
|
37
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
def matches(self, topic: str) -> bool:
|
|
40
|
+
return matches(self.topic, topic)
|
|
41
|
+
|
|
42
|
+
async def aclose(self) -> None:
|
|
43
|
+
"""Stop delivery for this subscription and release its queue."""
|
|
44
|
+
if not self.active and self.task is None:
|
|
45
|
+
return
|
|
46
|
+
self.active = False
|
|
47
|
+
task, self.task = self.task, None
|
|
48
|
+
if task is not None and not task.done():
|
|
49
|
+
task.cancel()
|
|
50
|
+
try:
|
|
51
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
52
|
+
except asyncio.CancelledError:
|
|
53
|
+
raise
|
|
54
|
+
try:
|
|
55
|
+
await self.queue.aclose()
|
|
56
|
+
except QueueClosedError: # pragma: no cover - already closed
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
def stats(self) -> dict[str, Any]:
|
|
60
|
+
buffer = self.queue.stats()
|
|
61
|
+
return {
|
|
62
|
+
"topic": self.topic,
|
|
63
|
+
"active": self.active,
|
|
64
|
+
"delivered": self.delivered,
|
|
65
|
+
"failed": self.failed,
|
|
66
|
+
"queued": buffer.size,
|
|
67
|
+
"dropped": buffer.dropped,
|
|
68
|
+
"high_watermark": buffer.high_watermark,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
|
72
|
+
return (
|
|
73
|
+
f"Subscription(topic={self.topic!r}, active={self.active}, "
|
|
74
|
+
f"delivered={self.delivered}, failed={self.failed})"
|
|
75
|
+
)
|