tigrbl_engine_memkv 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_memkv-0.1.10.dev1/.gitignore +22 -0
- tigrbl_engine_memkv-0.1.10.dev1/PKG-INFO +5 -0
- tigrbl_engine_memkv-0.1.10.dev1/README.md +23 -0
- tigrbl_engine_memkv-0.1.10.dev1/pyproject.toml +15 -0
- tigrbl_engine_memkv-0.1.10.dev1/src/tigrbl_engine_memkv/__init__.py +2 -0
- tigrbl_engine_memkv-0.1.10.dev1/src/tigrbl_engine_memkv/kv.py +153 -0
- tigrbl_engine_memkv-0.1.10.dev1/src/tigrbl_engine_memkv/plugin.py +40 -0
- tigrbl_engine_memkv-0.1.10.dev1/src/tigrbl_engine_memkv/session.py +55 -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_memkv
|
|
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_memkv`
|
|
20
|
+
- workspace class: engine package
|
|
21
|
+
- implementation layout: `src/tigrbl_engine_memkv/`
|
|
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_memkv"
|
|
7
|
+
version = "0.1.10.dev1"
|
|
8
|
+
requires-python = ">=3.10,<3.14"
|
|
9
|
+
dependencies = ["tigrbl"]
|
|
10
|
+
|
|
11
|
+
[project.entry-points."tigrbl.engine"]
|
|
12
|
+
memkv = "tigrbl_engine_memkv.plugin:register"
|
|
13
|
+
|
|
14
|
+
[tool.uv.sources]
|
|
15
|
+
tigrbl = { workspace = true }
|
|
@@ -0,0 +1,153 @@
|
|
|
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, Iterable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(order=True)
|
|
11
|
+
class _Exp:
|
|
12
|
+
expires_at: float
|
|
13
|
+
key: str
|
|
14
|
+
version: int
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class _Val:
|
|
19
|
+
version: int
|
|
20
|
+
value: Any
|
|
21
|
+
expires_at: float | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class KVStore:
|
|
25
|
+
"""Versioned in-memory KV with TTL and CAS.
|
|
26
|
+
|
|
27
|
+
Keyspace is namespaced at engine level.
|
|
28
|
+
TTL uses monotonic clock; cleanup is heap-based and amortized.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, *, namespace: str = "default", default_ttl_s: float | None = None, max_items: int = 1_000_000) -> None:
|
|
32
|
+
if max_items <= 0:
|
|
33
|
+
raise ValueError("max_items must be > 0")
|
|
34
|
+
if default_ttl_s is not None and default_ttl_s <= 0:
|
|
35
|
+
raise ValueError("default_ttl_s must be > 0 if set")
|
|
36
|
+
self.namespace = namespace
|
|
37
|
+
self.default_ttl_s = default_ttl_s
|
|
38
|
+
self.max_items = int(max_items)
|
|
39
|
+
|
|
40
|
+
self._lock = RLock()
|
|
41
|
+
self._m: dict[str, _Val] = {}
|
|
42
|
+
self._h: list[_Exp] = []
|
|
43
|
+
|
|
44
|
+
def _gc(self, now: float) -> None:
|
|
45
|
+
while self._h:
|
|
46
|
+
e = self._h[0]
|
|
47
|
+
if e.expires_at > now:
|
|
48
|
+
return
|
|
49
|
+
heapq.heappop(self._h)
|
|
50
|
+
cur = self._m.get(e.key)
|
|
51
|
+
if cur is None:
|
|
52
|
+
continue
|
|
53
|
+
# only evict if version matches and is actually expired
|
|
54
|
+
if cur.version == e.version and cur.expires_at is not None and cur.expires_at <= now:
|
|
55
|
+
self._m.pop(e.key, None)
|
|
56
|
+
|
|
57
|
+
def _set(self, key: str, value: Any, ttl_s: float | None) -> int:
|
|
58
|
+
now = monotonic()
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._gc(now)
|
|
61
|
+
cur = self._m.get(key)
|
|
62
|
+
ver = 1 if cur is None else (cur.version + 1)
|
|
63
|
+
exp_at: float | None = None
|
|
64
|
+
if ttl_s is None:
|
|
65
|
+
ttl_s = self.default_ttl_s
|
|
66
|
+
if ttl_s is not None:
|
|
67
|
+
ttl = float(ttl_s)
|
|
68
|
+
if ttl <= 0:
|
|
69
|
+
raise ValueError("ttl_s must be > 0 if set")
|
|
70
|
+
exp_at = now + ttl
|
|
71
|
+
self._m[key] = _Val(version=ver, value=value, expires_at=exp_at)
|
|
72
|
+
if exp_at is not None:
|
|
73
|
+
heapq.heappush(self._h, _Exp(exp_at, key, ver))
|
|
74
|
+
self._enforce_max(now)
|
|
75
|
+
return ver
|
|
76
|
+
|
|
77
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
78
|
+
now = monotonic()
|
|
79
|
+
with self._lock:
|
|
80
|
+
self._gc(now)
|
|
81
|
+
v = self._m.get(key)
|
|
82
|
+
if v is None:
|
|
83
|
+
return default
|
|
84
|
+
if v.expires_at is not None and v.expires_at <= now:
|
|
85
|
+
self._m.pop(key, None)
|
|
86
|
+
return default
|
|
87
|
+
return v.value
|
|
88
|
+
|
|
89
|
+
def get_version(self, key: str) -> int | None:
|
|
90
|
+
now = monotonic()
|
|
91
|
+
with self._lock:
|
|
92
|
+
self._gc(now)
|
|
93
|
+
v = self._m.get(key)
|
|
94
|
+
if v is None:
|
|
95
|
+
return None
|
|
96
|
+
if v.expires_at is not None and v.expires_at <= now:
|
|
97
|
+
self._m.pop(key, None)
|
|
98
|
+
return None
|
|
99
|
+
return v.version
|
|
100
|
+
|
|
101
|
+
def set(self, key: str, value: Any, *, ttl_s: float | None = None) -> int:
|
|
102
|
+
return self._set(key, value, ttl_s)
|
|
103
|
+
|
|
104
|
+
def cas(self, key: str, expected_version: int, value: Any, *, ttl_s: float | None = None) -> int | None:
|
|
105
|
+
now = monotonic()
|
|
106
|
+
with self._lock:
|
|
107
|
+
self._gc(now)
|
|
108
|
+
cur = self._m.get(key)
|
|
109
|
+
if cur is None:
|
|
110
|
+
return None
|
|
111
|
+
if cur.expires_at is not None and cur.expires_at <= now:
|
|
112
|
+
self._m.pop(key, None)
|
|
113
|
+
return None
|
|
114
|
+
if cur.version != int(expected_version):
|
|
115
|
+
return None
|
|
116
|
+
# perform write outside of read lock? keep simple: do within lock
|
|
117
|
+
with self._lock:
|
|
118
|
+
self._gc(monotonic())
|
|
119
|
+
cur2 = self._m.get(key)
|
|
120
|
+
if cur2 is None or cur2.version != int(expected_version):
|
|
121
|
+
return None
|
|
122
|
+
return self._set(key, value, ttl_s)
|
|
123
|
+
|
|
124
|
+
def delete(self, key: str) -> bool:
|
|
125
|
+
now = monotonic()
|
|
126
|
+
with self._lock:
|
|
127
|
+
self._gc(now)
|
|
128
|
+
return self._m.pop(key, None) is not None
|
|
129
|
+
|
|
130
|
+
def keys(self, prefix: str = "") -> list[str]:
|
|
131
|
+
now = monotonic()
|
|
132
|
+
with self._lock:
|
|
133
|
+
self._gc(now)
|
|
134
|
+
if not prefix:
|
|
135
|
+
return sorted(self._m.keys())
|
|
136
|
+
return sorted(k for k in self._m.keys() if k.startswith(prefix))
|
|
137
|
+
|
|
138
|
+
def reset(self) -> None:
|
|
139
|
+
with self._lock:
|
|
140
|
+
self._m.clear()
|
|
141
|
+
self._h.clear()
|
|
142
|
+
|
|
143
|
+
def stats(self) -> dict[str, Any]:
|
|
144
|
+
now = monotonic()
|
|
145
|
+
with self._lock:
|
|
146
|
+
self._gc(now)
|
|
147
|
+
return {"size": len(self._m), "max_items": self.max_items, "default_ttl_s": self.default_ttl_s}
|
|
148
|
+
|
|
149
|
+
def _enforce_max(self, now: float) -> None:
|
|
150
|
+
# Evict arbitrary oldest-expiring entries to enforce cap
|
|
151
|
+
while len(self._m) > self.max_items and self._h:
|
|
152
|
+
e = heapq.heappop(self._h)
|
|
153
|
+
self._m.pop(e.key, None)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from tigrbl.engine.registry import register_engine
|
|
4
|
+
|
|
5
|
+
from .kv import KVStore
|
|
6
|
+
from .session import KVSession, AsyncKVSession
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register() -> None:
|
|
10
|
+
register_engine(kind="memkv", build=build_memkv, capabilities=capabilities)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def capabilities() -> dict:
|
|
14
|
+
return {
|
|
15
|
+
"engine": "memkv",
|
|
16
|
+
"transactional": False,
|
|
17
|
+
"async_native": True,
|
|
18
|
+
"persistence": "process",
|
|
19
|
+
"features": {"kv", "ttl_optional", "cas", "versioned"},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_memkv(*, mapping=None, spec=None, dsn=None, **_) -> tuple[object, object]:
|
|
24
|
+
mapping = dict(mapping or {})
|
|
25
|
+
async_ = bool(getattr(spec, "async_", False))
|
|
26
|
+
|
|
27
|
+
namespace = str(mapping.get("namespace", "default"))
|
|
28
|
+
default_ttl_s = float(mapping.get("default_ttl_s", 0.0)) or None
|
|
29
|
+
max_items = int(mapping.get("max_items", 1_000_000))
|
|
30
|
+
|
|
31
|
+
engine = KVStore(namespace=namespace, default_ttl_s=default_ttl_s, max_items=max_items)
|
|
32
|
+
|
|
33
|
+
if async_:
|
|
34
|
+
def sessionmaker():
|
|
35
|
+
return AsyncKVSession(engine)
|
|
36
|
+
else:
|
|
37
|
+
def sessionmaker():
|
|
38
|
+
return KVSession(engine)
|
|
39
|
+
|
|
40
|
+
return engine, sessionmaker
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .kv import KVStore
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KVSession:
|
|
9
|
+
def __init__(self, engine: KVStore) -> None:
|
|
10
|
+
self._engine = engine
|
|
11
|
+
self._closed = False
|
|
12
|
+
|
|
13
|
+
def close(self) -> None:
|
|
14
|
+
self._closed = True
|
|
15
|
+
|
|
16
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
17
|
+
self._require_open()
|
|
18
|
+
return self._engine.get(key, default)
|
|
19
|
+
|
|
20
|
+
def get_version(self, key: str) -> int | None:
|
|
21
|
+
self._require_open()
|
|
22
|
+
return self._engine.get_version(key)
|
|
23
|
+
|
|
24
|
+
def set(self, key: str, value: Any, *, ttl_s: float | None = None) -> int:
|
|
25
|
+
self._require_open()
|
|
26
|
+
return self._engine.set(key, value, ttl_s=ttl_s)
|
|
27
|
+
|
|
28
|
+
def cas(self, key: str, expected_version: int, value: Any, *, ttl_s: float | None = None) -> int | None:
|
|
29
|
+
self._require_open()
|
|
30
|
+
return self._engine.cas(key, expected_version, value, ttl_s=ttl_s)
|
|
31
|
+
|
|
32
|
+
def delete(self, key: str) -> bool:
|
|
33
|
+
self._require_open()
|
|
34
|
+
return self._engine.delete(key)
|
|
35
|
+
|
|
36
|
+
def keys(self, prefix: str = "") -> list[str]:
|
|
37
|
+
self._require_open()
|
|
38
|
+
return self._engine.keys(prefix)
|
|
39
|
+
|
|
40
|
+
def reset(self) -> None:
|
|
41
|
+
self._require_open()
|
|
42
|
+
self._engine.reset()
|
|
43
|
+
|
|
44
|
+
def stats(self) -> dict:
|
|
45
|
+
self._require_open()
|
|
46
|
+
return self._engine.stats()
|
|
47
|
+
|
|
48
|
+
def _require_open(self) -> None:
|
|
49
|
+
if self._closed:
|
|
50
|
+
raise RuntimeError("session is closed")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AsyncKVSession(KVSession):
|
|
54
|
+
async def close(self) -> None: # type: ignore[override]
|
|
55
|
+
super().close()
|