tigrbl_engine_memrate 0.1.10.dev1__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.
- tigrbl_engine_memrate/__init__.py +2 -0
- tigrbl_engine_memrate/plugin.py +46 -0
- tigrbl_engine_memrate/rate.py +101 -0
- tigrbl_engine_memrate/session.py +33 -0
- tigrbl_engine_memrate-0.1.10.dev1.dist-info/METADATA +5 -0
- tigrbl_engine_memrate-0.1.10.dev1.dist-info/RECORD +8 -0
- tigrbl_engine_memrate-0.1.10.dev1.dist-info/WHEEL +4 -0
- tigrbl_engine_memrate-0.1.10.dev1.dist-info/entry_points.txt +2 -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()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
tigrbl_engine_memrate/__init__.py,sha256=tXbRXsO0NE_UV1kIHiZTTQQH0fj0U2KoxxNusu_gzrM,48
|
|
2
|
+
tigrbl_engine_memrate/plugin.py,sha256=NkbCk78TUwNeeDLdkG2nALXJWBUOV17vR4ZU9vE70GA,1230
|
|
3
|
+
tigrbl_engine_memrate/rate.py,sha256=UdESaSlW6CPIbCYAZIaO0v1XgRfSNLsZlNuDCh8PD0Q,3279
|
|
4
|
+
tigrbl_engine_memrate/session.py,sha256=td_Q5rHDyA-NzS9nalG8MtfWhkUXLsaYi_n3Qn60zxo,898
|
|
5
|
+
tigrbl_engine_memrate-0.1.10.dev1.dist-info/METADATA,sha256=mebBot6eunggwP6_wl47mkqgxjFB58DVQE-Cl5kdkrU,123
|
|
6
|
+
tigrbl_engine_memrate-0.1.10.dev1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
tigrbl_engine_memrate-0.1.10.dev1.dist-info/entry_points.txt,sha256=jnLamtkEei9oaiQ4C29cj6LdjZtVH0MyiUvYCrMpoNc,64
|
|
8
|
+
tigrbl_engine_memrate-0.1.10.dev1.dist-info/RECORD,,
|