tigrbl_engine_memrate 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.
@@ -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,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: tigrbl_engine_memrate
3
+ Version: 0.1.10.dev1
4
+ Requires-Python: <3.14,>=3.10
5
+ Requires-Dist: tigrbl
@@ -0,0 +1,23 @@
1
+ # tigrbl_engine_memrate
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_memrate`
20
+ - workspace class: engine package
21
+ - implementation layout: `src/tigrbl_engine_memrate/`
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_memrate"
7
+ version = "0.1.10.dev1"
8
+ requires-python = ">=3.10,<3.14"
9
+ dependencies = ["tigrbl"]
10
+
11
+ [project.entry-points."tigrbl.engine"]
12
+ memrate = "tigrbl_engine_memrate.plugin:register"
13
+
14
+ [tool.uv.sources]
15
+ tigrbl = { workspace = true }
@@ -0,0 +1,2 @@
1
+ __all__ = ["__version__"]
2
+ __version__ = "0.1.0"
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from tigrbl.engine.registry import register_engine
4
+
5
+ from .rate import RateLimiter
6
+ from .session import RateSession, AsyncRateSession
7
+
8
+
9
+ def register() -> None:
10
+ register_engine(kind="memrate", build=build_memrate, capabilities=capabilities)
11
+
12
+
13
+ def capabilities() -> dict:
14
+ return {
15
+ "engine": "memrate",
16
+ "transactional": False,
17
+ "async_native": True,
18
+ "persistence": "process",
19
+ "features": {"token_bucket", "reserve"},
20
+ }
21
+
22
+
23
+ def build_memrate(*, mapping=None, spec=None, dsn=None, **_) -> tuple[object, object]:
24
+ mapping = dict(mapping or {})
25
+ async_ = bool(getattr(spec, "async_", False))
26
+
27
+ rate_per_s = float(mapping.get("rate_per_s", 10.0))
28
+ burst = float(mapping.get("burst", max(1.0, rate_per_s)))
29
+ shards = int(mapping.get("shards", 64))
30
+ namespace = str(mapping.get("namespace", "default"))
31
+
32
+ engine = RateLimiter(
33
+ rate_per_s=rate_per_s,
34
+ burst=burst,
35
+ shards=shards,
36
+ namespace=namespace,
37
+ )
38
+
39
+ if async_:
40
+ def sessionmaker():
41
+ return AsyncRateSession(engine)
42
+ else:
43
+ def sessionmaker():
44
+ return RateSession(engine)
45
+
46
+ return engine, sessionmaker
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from threading import RLock
5
+ from time import monotonic
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class _Bucket:
11
+ tokens: float
12
+ t_last: float
13
+
14
+
15
+ class RateLimiter:
16
+ """Token-bucket limiter: allow/reserve by arbitrary key.
17
+
18
+ Semantics:
19
+ - rate_per_s: refill rate
20
+ - burst: max tokens
21
+ - cost: tokens consumed per request
22
+ - allow() returns bool
23
+ - reserve() returns wait_s (0 if immediate) or None if impossible (cost > burst)
24
+ """
25
+
26
+ def __init__(self, *, rate_per_s: float, burst: float, shards: int = 64, namespace: str = "default") -> None:
27
+ if rate_per_s <= 0:
28
+ raise ValueError("rate_per_s must be > 0")
29
+ if burst <= 0:
30
+ raise ValueError("burst must be > 0")
31
+ if shards <= 0:
32
+ raise ValueError("shards must be > 0")
33
+ self.rate_per_s = float(rate_per_s)
34
+ self.burst = float(burst)
35
+ self.shards = int(shards)
36
+ self.namespace = namespace
37
+
38
+ self._locks = [RLock() for _ in range(self.shards)]
39
+ self._maps: list[dict[str, _Bucket]] = [dict() for _ in range(self.shards)]
40
+
41
+ def _idx(self, key: str) -> int:
42
+ # deterministic per-process; good enough for sharding
43
+ return (hash((self.namespace, key)) & 0x7FFFFFFF) % self.shards
44
+
45
+ def _refill(self, b: _Bucket, now: float) -> None:
46
+ dt = now - b.t_last
47
+ if dt <= 0:
48
+ return
49
+ b.tokens = min(self.burst, b.tokens + dt * self.rate_per_s)
50
+ b.t_last = now
51
+
52
+ def _get_bucket(self, m: dict[str, _Bucket], key: str, now: float) -> _Bucket:
53
+ b = m.get(key)
54
+ if b is None:
55
+ b = _Bucket(tokens=self.burst, t_last=now)
56
+ m[key] = b
57
+ return b
58
+
59
+ def allow(self, key: str, *, cost: float = 1.0) -> bool:
60
+ wait = self.reserve(key, cost=cost)
61
+ return wait == 0.0
62
+
63
+ def reserve(self, key: str, *, cost: float = 1.0) -> float | None:
64
+ """Reserve tokens; returns wait seconds if not immediately available.
65
+
66
+ - If cost > burst, returns None (impossible).
67
+ - Otherwise deducts immediately if available; else returns required wait time WITHOUT deducting.
68
+ Caller may sleep and retry, or use wait to schedule.
69
+ """
70
+ if cost <= 0:
71
+ return 0.0
72
+ if cost > self.burst:
73
+ return None
74
+
75
+ now = monotonic()
76
+ i = self._idx(key)
77
+ with self._locks[i]:
78
+ m = self._maps[i]
79
+ b = self._get_bucket(m, key, now)
80
+ self._refill(b, now)
81
+ if b.tokens >= cost:
82
+ b.tokens -= cost
83
+ return 0.0
84
+ deficit = cost - b.tokens
85
+ # time until enough tokens accumulate
86
+ wait_s = deficit / self.rate_per_s
87
+ return max(0.0, wait_s)
88
+
89
+ def stats(self, key: str) -> dict[str, Any]:
90
+ now = monotonic()
91
+ i = self._idx(key)
92
+ with self._locks[i]:
93
+ m = self._maps[i]
94
+ b = self._get_bucket(m, key, now)
95
+ self._refill(b, now)
96
+ return {
97
+ "key": key,
98
+ "tokens": b.tokens,
99
+ "burst": self.burst,
100
+ "rate_per_s": self.rate_per_s,
101
+ }
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from .rate import RateLimiter
4
+
5
+
6
+ class RateSession:
7
+ def __init__(self, engine: RateLimiter) -> None:
8
+ self._engine = engine
9
+ self._closed = False
10
+
11
+ def close(self) -> None:
12
+ self._closed = True
13
+
14
+ def allow(self, key: str, *, cost: float = 1.0) -> bool:
15
+ self._require_open()
16
+ return self._engine.allow(key, cost=cost)
17
+
18
+ def reserve(self, key: str, *, cost: float = 1.0) -> float | None:
19
+ self._require_open()
20
+ return self._engine.reserve(key, cost=cost)
21
+
22
+ def stats(self, key: str) -> dict:
23
+ self._require_open()
24
+ return self._engine.stats(key)
25
+
26
+ def _require_open(self) -> None:
27
+ if self._closed:
28
+ raise RuntimeError("session is closed")
29
+
30
+
31
+ class AsyncRateSession(RateSession):
32
+ async def close(self) -> None: # type: ignore[override]
33
+ super().close()