tigrbl_engine_memdedupe 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.
@@ -0,0 +1,2 @@
1
+ __all__ = ["__version__"]
2
+ __version__ = "0.1.0"
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import heapq
4
+ from dataclasses import dataclass
5
+ from threading import RLock
6
+ from time import monotonic
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(order=True)
11
+ class _Exp:
12
+ expires_at: float
13
+ key: str
14
+
15
+
16
+ class DedupeSet:
17
+ """Exact TTL membership set with bounded size.
18
+
19
+ Stores: key -> expires_at (monotonic seconds)
20
+ Cleanup: min-heap of expirations for amortized O(log n) eviction.
21
+ """
22
+
23
+ def __init__(self, *, default_ttl_s: float = 60.0, max_items: int = 1_000_000, namespace: str = "default") -> None:
24
+ if default_ttl_s <= 0:
25
+ raise ValueError("default_ttl_s must be > 0")
26
+ if max_items <= 0:
27
+ raise ValueError("max_items must be > 0")
28
+ self.default_ttl_s = float(default_ttl_s)
29
+ self.max_items = int(max_items)
30
+ self.namespace = namespace
31
+
32
+ self._lock = RLock()
33
+ self._m: dict[str, float] = {}
34
+ self._h: list[_Exp] = []
35
+
36
+ def _gc(self, now: float) -> None:
37
+ # Pop until heap head is not expired or stale.
38
+ while self._h:
39
+ exp = self._h[0]
40
+ if exp.expires_at > now:
41
+ return
42
+ heapq.heappop(self._h)
43
+ cur = self._m.get(exp.key)
44
+ if cur is None:
45
+ continue
46
+ if cur <= now:
47
+ self._m.pop(exp.key, None)
48
+
49
+ def seen(self, key: str) -> bool:
50
+ now = monotonic()
51
+ with self._lock:
52
+ self._gc(now)
53
+ exp = self._m.get(key)
54
+ return exp is not None and exp > now
55
+
56
+ def mark(self, key: str, *, ttl_s: float | None = None) -> None:
57
+ now = monotonic()
58
+ ttl = self.default_ttl_s if ttl_s is None else float(ttl_s)
59
+ if ttl <= 0:
60
+ raise ValueError("ttl_s must be > 0")
61
+ exp_at = now + ttl
62
+ with self._lock:
63
+ self._gc(now)
64
+ self._m[key] = exp_at
65
+ heapq.heappush(self._h, _Exp(exp_at, key))
66
+ self._enforce_max(now)
67
+
68
+ def mark_if_absent(self, key: str, *, ttl_s: float | None = None) -> bool:
69
+ now = monotonic()
70
+ ttl = self.default_ttl_s if ttl_s is None else float(ttl_s)
71
+ if ttl <= 0:
72
+ raise ValueError("ttl_s must be > 0")
73
+ exp_at = now + ttl
74
+ with self._lock:
75
+ self._gc(now)
76
+ cur = self._m.get(key)
77
+ if cur is not None and cur > now:
78
+ return False
79
+ self._m[key] = exp_at
80
+ heapq.heappush(self._h, _Exp(exp_at, key))
81
+ self._enforce_max(now)
82
+ return True
83
+
84
+ def delete(self, key: str) -> bool:
85
+ now = monotonic()
86
+ with self._lock:
87
+ self._gc(now)
88
+ return self._m.pop(key, None) is not None
89
+
90
+ def size(self) -> int:
91
+ now = monotonic()
92
+ with self._lock:
93
+ self._gc(now)
94
+ return len(self._m)
95
+
96
+ def reset(self) -> None:
97
+ with self._lock:
98
+ self._m.clear()
99
+ self._h.clear()
100
+
101
+ def stats(self) -> dict[str, Any]:
102
+ now = monotonic()
103
+ with self._lock:
104
+ self._gc(now)
105
+ return {"size": len(self._m), "max_items": self.max_items, "default_ttl_s": self.default_ttl_s}
106
+
107
+ def _enforce_max(self, now: float) -> None:
108
+ # If above max, evict oldest-expiring keys (heap order)
109
+ while len(self._m) > self.max_items and self._h:
110
+ exp = heapq.heappop(self._h)
111
+ cur = self._m.get(exp.key)
112
+ if cur is None:
113
+ continue
114
+ # evict regardless of expiration order; this is a hard cap
115
+ self._m.pop(exp.key, None)
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from tigrbl.engine.registry import register_engine
4
+
5
+ from .dedupe import DedupeSet
6
+ from .session import DedupeSession, AsyncDedupeSession
7
+
8
+
9
+ def register() -> None:
10
+ register_engine(kind="memdedupe", build=build_memdedupe, capabilities=capabilities)
11
+
12
+
13
+ def capabilities() -> dict:
14
+ return {
15
+ "engine": "memdedupe",
16
+ "transactional": False,
17
+ "async_native": True,
18
+ "persistence": "process",
19
+ "features": {"ttl_set", "exact_membership"},
20
+ }
21
+
22
+
23
+ def build_memdedupe(*, mapping=None, spec=None, dsn=None, **_) -> tuple[object, object]:
24
+ mapping = dict(mapping or {})
25
+ async_ = bool(getattr(spec, "async_", False))
26
+
27
+ default_ttl_s = float(mapping.get("default_ttl_s", 60.0))
28
+ max_items = int(mapping.get("max_items", 1_000_000))
29
+ namespace = str(mapping.get("namespace", "default"))
30
+
31
+ engine = DedupeSet(default_ttl_s=default_ttl_s, max_items=max_items, namespace=namespace)
32
+
33
+ if async_:
34
+ def sessionmaker():
35
+ return AsyncDedupeSession(engine)
36
+ else:
37
+ def sessionmaker():
38
+ return DedupeSession(engine)
39
+
40
+ return engine, sessionmaker
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from .dedupe import DedupeSet
4
+
5
+
6
+ class DedupeSession:
7
+ def __init__(self, engine: DedupeSet) -> None:
8
+ self._engine = engine
9
+ self._closed = False
10
+
11
+ def close(self) -> None:
12
+ self._closed = True
13
+
14
+ def seen(self, key: str) -> bool:
15
+ self._require_open()
16
+ return self._engine.seen(key)
17
+
18
+ def mark(self, key: str, *, ttl_s: float | None = None) -> None:
19
+ self._require_open()
20
+ self._engine.mark(key, ttl_s=ttl_s)
21
+
22
+ def mark_if_absent(self, key: str, *, ttl_s: float | None = None) -> bool:
23
+ self._require_open()
24
+ return self._engine.mark_if_absent(key, ttl_s=ttl_s)
25
+
26
+ def delete(self, key: str) -> bool:
27
+ self._require_open()
28
+ return self._engine.delete(key)
29
+
30
+ def size(self) -> int:
31
+ self._require_open()
32
+ return self._engine.size()
33
+
34
+ def reset(self) -> None:
35
+ self._require_open()
36
+ self._engine.reset()
37
+
38
+ def stats(self) -> dict:
39
+ self._require_open()
40
+ return self._engine.stats()
41
+
42
+ def _require_open(self) -> None:
43
+ if self._closed:
44
+ raise RuntimeError("session is closed")
45
+
46
+
47
+ class AsyncDedupeSession(DedupeSession):
48
+ async def close(self) -> None: # type: ignore[override]
49
+ super().close()
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: tigrbl_engine_memdedupe
3
+ Version: 0.1.10.dev1
4
+ Requires-Python: <3.14,>=3.10
5
+ Requires-Dist: tigrbl
@@ -0,0 +1,8 @@
1
+ tigrbl_engine_memdedupe/__init__.py,sha256=tXbRXsO0NE_UV1kIHiZTTQQH0fj0U2KoxxNusu_gzrM,48
2
+ tigrbl_engine_memdedupe/dedupe.py,sha256=b4gIY1U1096Qi914YP9m-cxVq6u87ED3BlDcyQkE-gY,3630
3
+ tigrbl_engine_memdedupe/plugin.py,sha256=uYw35FQ1SnBNwk4QbgH_cJIeyRbacadX6oT4vr8yCgo,1165
4
+ tigrbl_engine_memdedupe/session.py,sha256=WdjjL-7UmnCMKUhNmySjqQ8wwwjujgc4i_0tNtan_G4,1304
5
+ tigrbl_engine_memdedupe-0.1.10.dev1.dist-info/METADATA,sha256=JrW4fxxxp6ZeE7cFotFdYFAZ8kRAbCTaBYEfLexYzMc,125
6
+ tigrbl_engine_memdedupe-0.1.10.dev1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
7
+ tigrbl_engine_memdedupe-0.1.10.dev1.dist-info/entry_points.txt,sha256=4UegOfnPR5IkocZv1ocrurBXgkUYRPEnZi9WlUO5kAo,68
8
+ tigrbl_engine_memdedupe-0.1.10.dev1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [tigrbl.engine]
2
+ memdedupe = tigrbl_engine_memdedupe.plugin:register