tigrbl_engine_mempubsub 0.1.10.dev1__tar.gz
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.
- tigrbl_engine_mempubsub-0.1.10.dev1/.gitignore +22 -0
- tigrbl_engine_mempubsub-0.1.10.dev1/PKG-INFO +5 -0
- tigrbl_engine_mempubsub-0.1.10.dev1/README.md +23 -0
- tigrbl_engine_mempubsub-0.1.10.dev1/pyproject.toml +15 -0
- tigrbl_engine_mempubsub-0.1.10.dev1/src/tigrbl_engine_mempubsub/__init__.py +2 -0
- tigrbl_engine_mempubsub-0.1.10.dev1/src/tigrbl_engine_mempubsub/plugin.py +40 -0
- tigrbl_engine_mempubsub-0.1.10.dev1/src/tigrbl_engine_mempubsub/pubsub.py +123 -0
- tigrbl_engine_mempubsub-0.1.10.dev1/src/tigrbl_engine_mempubsub/session.py +35 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
.pytest_cache/
|
|
3
|
+
.ruff_cache/
|
|
4
|
+
.mypy_cache/
|
|
5
|
+
.tox/
|
|
6
|
+
.nox/
|
|
7
|
+
.venv/
|
|
8
|
+
.coverage
|
|
9
|
+
htmlcov/
|
|
10
|
+
build/
|
|
11
|
+
dist/
|
|
12
|
+
site/
|
|
13
|
+
target/
|
|
14
|
+
*.egg-info/
|
|
15
|
+
*.pyc
|
|
16
|
+
*.zip
|
|
17
|
+
/.vendor
|
|
18
|
+
/.tmp
|
|
19
|
+
*.body
|
|
20
|
+
.pip-cache/
|
|
21
|
+
*.pyd
|
|
22
|
+
.tmp-release-plan-check.json
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# tigrbl_engine_mempubsub
|
|
2
|
+
|
|
3
|
+
This file is a package-local distribution entry point.
|
|
4
|
+
It is not the authoritative location for repository governance, current target status, current state reporting, certification claims, or release evidence.
|
|
5
|
+
|
|
6
|
+
## Canonical repository docs
|
|
7
|
+
|
|
8
|
+
- `README.md`
|
|
9
|
+
- `docs/README.md`
|
|
10
|
+
- `docs/conformance/CURRENT_TARGET.md`
|
|
11
|
+
- `docs/conformance/CURRENT_STATE.md`
|
|
12
|
+
- `docs/conformance/NEXT_STEPS.md`
|
|
13
|
+
- `docs/governance/DOC_POINTERS.md`
|
|
14
|
+
- `docs/developer/PACKAGE_CATALOG.md`
|
|
15
|
+
- `docs/developer/PACKAGE_LAYOUT.md`
|
|
16
|
+
|
|
17
|
+
## Package identity
|
|
18
|
+
|
|
19
|
+
- workspace path: `pkgs/engines/tigrbl_engine_mempubsub`
|
|
20
|
+
- workspace class: engine package
|
|
21
|
+
- implementation layout: `src/tigrbl_engine_mempubsub/`
|
|
22
|
+
|
|
23
|
+
Long-form repository documentation is governed from `docs/`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tigrbl_engine_mempubsub"
|
|
7
|
+
version = "0.1.10.dev1"
|
|
8
|
+
requires-python = ">=3.10,<3.14"
|
|
9
|
+
dependencies = ["tigrbl"]
|
|
10
|
+
|
|
11
|
+
[project.entry-points."tigrbl.engine"]
|
|
12
|
+
mempubsub = "tigrbl_engine_mempubsub.plugin:register"
|
|
13
|
+
|
|
14
|
+
[tool.uv.sources]
|
|
15
|
+
tigrbl = { workspace = true }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from tigrbl.engine.registry import register_engine
|
|
4
|
+
|
|
5
|
+
from .pubsub import PubSubHub
|
|
6
|
+
from .session import PubSubSession, AsyncPubSubSession
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register() -> None:
|
|
10
|
+
register_engine(kind="mempubsub", build=build_mempubsub, capabilities=capabilities)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def capabilities() -> dict:
|
|
14
|
+
return {
|
|
15
|
+
"engine": "mempubsub",
|
|
16
|
+
"transactional": False,
|
|
17
|
+
"async_native": True,
|
|
18
|
+
"persistence": "process",
|
|
19
|
+
"features": {"topics", "fanout", "subscriptions"},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_mempubsub(*, mapping=None, spec=None, dsn=None, **_) -> tuple[object, object]:
|
|
24
|
+
mapping = dict(mapping or {})
|
|
25
|
+
async_ = bool(getattr(spec, "async_", False))
|
|
26
|
+
|
|
27
|
+
max_backlog = int(mapping.get("max_backlog_per_sub", 1024))
|
|
28
|
+
drop_policy = str(mapping.get("drop_policy", "drop_old")).lower()
|
|
29
|
+
namespace = str(mapping.get("namespace", "default"))
|
|
30
|
+
|
|
31
|
+
engine = PubSubHub(max_backlog_per_sub=max_backlog, drop_policy=drop_policy, namespace=namespace)
|
|
32
|
+
|
|
33
|
+
if async_:
|
|
34
|
+
def sessionmaker():
|
|
35
|
+
return AsyncPubSubSession(engine)
|
|
36
|
+
else:
|
|
37
|
+
def sessionmaker():
|
|
38
|
+
return PubSubSession(engine)
|
|
39
|
+
|
|
40
|
+
return engine, sessionmaker
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from threading import Condition, RLock
|
|
6
|
+
from time import monotonic
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class _Sub:
|
|
12
|
+
lock: RLock
|
|
13
|
+
cv: Condition
|
|
14
|
+
dq: deque[Any]
|
|
15
|
+
closed: bool = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Subscription:
|
|
19
|
+
def __init__(self, hub: "PubSubHub", topic: str, sub: _Sub) -> None:
|
|
20
|
+
self._hub = hub
|
|
21
|
+
self._topic = topic
|
|
22
|
+
self._sub = sub
|
|
23
|
+
self._closed = False
|
|
24
|
+
|
|
25
|
+
def recv(self, timeout: float | None = None) -> Any | None:
|
|
26
|
+
if self._closed:
|
|
27
|
+
return None
|
|
28
|
+
deadline = None if timeout is None else (monotonic() + max(0.0, float(timeout)))
|
|
29
|
+
s = self._sub
|
|
30
|
+
with s.lock:
|
|
31
|
+
while not s.closed and not s.dq:
|
|
32
|
+
if deadline is None:
|
|
33
|
+
s.cv.wait()
|
|
34
|
+
else:
|
|
35
|
+
remain = deadline - monotonic()
|
|
36
|
+
if remain <= 0:
|
|
37
|
+
return None
|
|
38
|
+
s.cv.wait(remain)
|
|
39
|
+
if not s.dq:
|
|
40
|
+
return None
|
|
41
|
+
return s.dq.popleft()
|
|
42
|
+
|
|
43
|
+
def close(self) -> None:
|
|
44
|
+
if self._closed:
|
|
45
|
+
return
|
|
46
|
+
self._closed = True
|
|
47
|
+
self._hub._unsubscribe(self._topic, self._sub)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def topic(self) -> str:
|
|
51
|
+
return self._topic
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PubSubHub:
|
|
55
|
+
"""In-process pub/sub with fanout to per-subscriber queues."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, *, max_backlog_per_sub: int = 1024, drop_policy: str = "drop_old", namespace: str = "default") -> None:
|
|
58
|
+
if max_backlog_per_sub <= 0:
|
|
59
|
+
raise ValueError("max_backlog_per_sub must be > 0")
|
|
60
|
+
drop_policy = (drop_policy or "drop_old").lower()
|
|
61
|
+
if drop_policy not in {"drop_old", "drop_new", "block"}:
|
|
62
|
+
raise ValueError("drop_policy must be drop_old|drop_new|block")
|
|
63
|
+
self.max_backlog_per_sub = int(max_backlog_per_sub)
|
|
64
|
+
self.drop_policy = drop_policy
|
|
65
|
+
self.namespace = namespace
|
|
66
|
+
|
|
67
|
+
self._lock = RLock()
|
|
68
|
+
self._topics: dict[str, set[_Sub]] = {}
|
|
69
|
+
|
|
70
|
+
def subscribe(self, topic: str) -> Subscription:
|
|
71
|
+
t = topic or "default"
|
|
72
|
+
sub_lock = RLock()
|
|
73
|
+
sub = _Sub(lock=sub_lock, cv=Condition(sub_lock), dq=deque())
|
|
74
|
+
with self._lock:
|
|
75
|
+
self._topics.setdefault(t, set()).add(sub)
|
|
76
|
+
return Subscription(self, t, sub)
|
|
77
|
+
|
|
78
|
+
def _unsubscribe(self, topic: str, sub: _Sub) -> None:
|
|
79
|
+
with self._lock:
|
|
80
|
+
s = self._topics.get(topic)
|
|
81
|
+
if not s:
|
|
82
|
+
return
|
|
83
|
+
s.discard(sub)
|
|
84
|
+
if not s:
|
|
85
|
+
self._topics.pop(topic, None)
|
|
86
|
+
with sub.lock:
|
|
87
|
+
sub.closed = True
|
|
88
|
+
sub.cv.notify_all()
|
|
89
|
+
|
|
90
|
+
def publish(self, topic: str, msg: Any, *, timeout: float | None = None) -> int:
|
|
91
|
+
t = topic or "default"
|
|
92
|
+
deadline = None if timeout is None else (monotonic() + max(0.0, float(timeout)))
|
|
93
|
+
with self._lock:
|
|
94
|
+
subs = list(self._topics.get(t, ()))
|
|
95
|
+
delivered = 0
|
|
96
|
+
for sub in subs:
|
|
97
|
+
with sub.lock:
|
|
98
|
+
if sub.closed:
|
|
99
|
+
continue
|
|
100
|
+
if len(sub.dq) >= self.max_backlog_per_sub:
|
|
101
|
+
if self.drop_policy == "drop_new":
|
|
102
|
+
continue
|
|
103
|
+
if self.drop_policy == "drop_old":
|
|
104
|
+
sub.dq.popleft()
|
|
105
|
+
else: # block
|
|
106
|
+
while not sub.closed and len(sub.dq) >= self.max_backlog_per_sub:
|
|
107
|
+
if deadline is None:
|
|
108
|
+
sub.cv.wait()
|
|
109
|
+
else:
|
|
110
|
+
remain = deadline - monotonic()
|
|
111
|
+
if remain <= 0:
|
|
112
|
+
break
|
|
113
|
+
sub.cv.wait(remain)
|
|
114
|
+
if sub.closed or len(sub.dq) >= self.max_backlog_per_sub:
|
|
115
|
+
continue
|
|
116
|
+
sub.dq.append(msg)
|
|
117
|
+
sub.cv.notify()
|
|
118
|
+
delivered += 1
|
|
119
|
+
return delivered
|
|
120
|
+
|
|
121
|
+
def topics(self) -> list[str]:
|
|
122
|
+
with self._lock:
|
|
123
|
+
return sorted(self._topics.keys())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .pubsub import PubSubHub, Subscription
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PubSubSession:
|
|
9
|
+
def __init__(self, engine: PubSubHub) -> None:
|
|
10
|
+
self._engine = engine
|
|
11
|
+
self._closed = False
|
|
12
|
+
|
|
13
|
+
def close(self) -> None:
|
|
14
|
+
self._closed = True
|
|
15
|
+
|
|
16
|
+
def publish(self, topic: str, msg: Any, *, timeout: float | None = None) -> int:
|
|
17
|
+
self._require_open()
|
|
18
|
+
return self._engine.publish(topic, msg, timeout=timeout)
|
|
19
|
+
|
|
20
|
+
def subscribe(self, topic: str) -> Subscription:
|
|
21
|
+
self._require_open()
|
|
22
|
+
return self._engine.subscribe(topic)
|
|
23
|
+
|
|
24
|
+
def topics(self) -> list[str]:
|
|
25
|
+
self._require_open()
|
|
26
|
+
return self._engine.topics()
|
|
27
|
+
|
|
28
|
+
def _require_open(self) -> None:
|
|
29
|
+
if self._closed:
|
|
30
|
+
raise RuntimeError("session is closed")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AsyncPubSubSession(PubSubSession):
|
|
34
|
+
async def close(self) -> None: # type: ignore[override]
|
|
35
|
+
super().close()
|