gpdatacached 0.1.0__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.
gpdatacached/domain.py ADDED
@@ -0,0 +1,362 @@
1
+ # src/gpdatacached/domain.py
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import threading
6
+ from typing import TYPE_CHECKING
7
+
8
+ import redis
9
+ from gpconfig import GPConfigurable
10
+
11
+ from .config import GPDCDomainConfig
12
+ from .keys import (
13
+ GLOBAL_META_KEY,
14
+ GLOBAL_DOMAINS_KEY,
15
+ make_domain_meta_key,
16
+ make_domain_namespaces_key,
17
+ make_namespace_meta_key,
18
+ validate_domain_name,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from .config import GPDCDomainConfig
23
+ from .namespace import GPDCNamespace
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Wire protocol version (Major.Minor), changes only when Redis data layout
28
+ # becomes incompatible. Do not confuse with the library __version__.
29
+ GPDC_PROTOCOL_VERSION = "1.0"
30
+
31
+
32
+ class GPDCDomain(GPConfigurable):
33
+ """GPDC domain: a Redis connection pool and the configuration root."""
34
+
35
+ def __init__(self, name: str, config: GPDCDomainConfig) -> None:
36
+ super().__init__(config)
37
+ validate_domain_name(name)
38
+ self._name = name
39
+ self._namespaces: dict[str, GPDCNamespace] = {}
40
+ self._gc_thread: threading.Thread | None = None
41
+ self._gc_stop_event = threading.Event()
42
+ self._closed = False
43
+ self._redis: redis.Redis = None # type: ignore[assignment]
44
+ self._connect()
45
+ self._start_gc()
46
+
47
+ def _connect(self) -> None:
48
+ cfg = self._config
49
+ pool = redis.ConnectionPool(
50
+ host=cfg.redis_host,
51
+ port=cfg.redis_port,
52
+ db=cfg.redis_db,
53
+ password=cfg.redis_password or None,
54
+ decode_responses=True,
55
+ )
56
+ self._redis = redis.Redis(connection_pool=pool)
57
+ self._init_global_meta()
58
+ self._init_domain_meta()
59
+ self._init_predefined_namespaces()
60
+
61
+ def _init_global_meta(self) -> None:
62
+ """Initialize or verify global GPDC metadata."""
63
+ existing = self._redis.hget(GLOBAL_META_KEY, "version")
64
+ if existing is None:
65
+ self._redis.hset(GLOBAL_META_KEY, "version", GPDC_PROTOCOL_VERSION)
66
+ elif existing != GPDC_PROTOCOL_VERSION:
67
+ raise RuntimeError(f"Incompatible GPDC version in Redis: {existing!r} (expected {GPDC_PROTOCOL_VERSION!r})")
68
+
69
+ def _init_domain_meta(self) -> None:
70
+ """Initialize or verify domain metadata in Redis.
71
+
72
+ Legacy fields (``default_cache_mode``, ``default_ttl_seconds``) keep
73
+ their existing silent write-once behaviour: written when the domain is
74
+ new, never compared against config thereafter.
75
+
76
+ Lock fields (``default_lock_ttl_seconds``, etc.) follow the
77
+ ``_init_global_meta`` write-or-verify pattern: written when new,
78
+ backfilled if missing, raise ``GPDCLockConfigInconsistentError`` on
79
+ mismatch.
80
+ """
81
+ cfg = self._config
82
+ meta_key = make_domain_meta_key(self._name)
83
+ existing = self._redis.hgetall(meta_key)
84
+
85
+ lock_fields = {
86
+ "default_lock_ttl_seconds": str(cfg.default_lock_ttl_seconds),
87
+ "default_lock_acquire_timeout_seconds": str(cfg.default_lock_acquire_timeout_seconds),
88
+ "gc_lock_ttl_seconds": str(cfg.gc_lock_ttl_seconds),
89
+ "gc_lock_wait_seconds": str(cfg.gc_lock_wait_seconds),
90
+ }
91
+
92
+ if not existing:
93
+ # New domain: write legacy + lock fields in one shot.
94
+ self._redis.hset(
95
+ meta_key,
96
+ mapping={
97
+ "default_cache_mode": cfg.default_cache_mode,
98
+ "default_ttl_seconds": str(cfg.default_ttl_seconds),
99
+ **lock_fields,
100
+ },
101
+ )
102
+ else:
103
+ # Existing domain.
104
+ # Legacy fields: untouched (silent write-once, matches prior behaviour).
105
+ # Lock fields: write-or-verify.
106
+ to_write: dict[str, str] = {}
107
+ conflicts: dict[str, tuple[str, str]] = {}
108
+ for field, expected in lock_fields.items():
109
+ actual = existing.get(field)
110
+ if actual is None:
111
+ to_write[field] = expected
112
+ elif actual != expected:
113
+ conflicts[field] = (actual, expected)
114
+
115
+ if conflicts:
116
+ from .exceptions import GPDCLockConfigInconsistentError
117
+
118
+ legal_values = {k: existing[k] for k in lock_fields if k in existing}
119
+ raise GPDCLockConfigInconsistentError(
120
+ domain=self._name,
121
+ conflicts=conflicts,
122
+ legal_values=legal_values,
123
+ )
124
+
125
+ if to_write:
126
+ self._redis.hset(meta_key, mapping=to_write)
127
+
128
+ # Always register this domain in the global domains set.
129
+ self._redis.sadd(GLOBAL_DOMAINS_KEY, self._name)
130
+
131
+ def _init_predefined_namespaces(self) -> None:
132
+ """Create predefined namespaces declared in config, applying per-namespace settings.
133
+
134
+ Delegates config-priority logic to GPDCNamespace.__init__: if the
135
+ namespace does not yet exist in Redis the per-namespace config is used;
136
+ if it already exists the Redis metadata is left untouched.
137
+ """
138
+ from .namespace import GPDCNamespace
139
+
140
+ for ns_name, ns_config in self._config.namespaces.items():
141
+ if ns_name not in self._namespaces:
142
+ self._namespaces[ns_name] = GPDCNamespace(ns_name, self, config=ns_config)
143
+
144
+ @property
145
+ def name(self) -> str:
146
+ return self._name
147
+
148
+ def __repr__(self) -> str:
149
+ return f"GPDCDomain({self._name!r})"
150
+
151
+ def _check_open(self) -> None:
152
+ if self._closed:
153
+ raise RuntimeError(f"GPDCDomain {self._name!r} is closed")
154
+
155
+ @property
156
+ def redis(self) -> redis.Redis:
157
+ self._check_open()
158
+ return self._redis
159
+
160
+ def ensure_namespace(self, name: str):
161
+ self._check_open()
162
+ from .namespace import GPDCNamespace
163
+
164
+ if name not in self._namespaces:
165
+ self._namespaces[name] = GPDCNamespace(name, self)
166
+ return self._namespaces[name]
167
+
168
+ def get_namespace(self, name: str):
169
+ return self[name]
170
+
171
+ def __contains__(self, name: object) -> bool:
172
+ self._check_open()
173
+ if not isinstance(name, str):
174
+ return False
175
+ return self._redis.sismember(make_domain_namespaces_key(self._name), name)
176
+
177
+ def __iter__(self):
178
+ self._check_open()
179
+ return iter(self._redis.smembers(make_domain_namespaces_key(self._name)))
180
+
181
+ def __len__(self) -> int:
182
+ self._check_open()
183
+ return self._redis.scard(make_domain_namespaces_key(self._name))
184
+
185
+ def __getitem__(self, name: str):
186
+ self._check_open()
187
+ if not self._redis.sismember(make_domain_namespaces_key(self._name), name):
188
+ raise KeyError(name)
189
+ if name not in self._namespaces:
190
+ from .namespace import GPDCNamespace
191
+
192
+ self._namespaces[name] = GPDCNamespace(name, self)
193
+ return self._namespaces[name]
194
+
195
+ def get_default_cache_mode(self) -> str:
196
+ """Effective default_cache_mode for this domain."""
197
+ meta_key = make_domain_meta_key(self._name)
198
+ val = self._redis.hget(meta_key, "default_cache_mode")
199
+ if val:
200
+ return val
201
+ return self._config.default_cache_mode
202
+
203
+ def get_default_ttl_seconds(self) -> int:
204
+ """Effective default_ttl_seconds for this domain."""
205
+ meta_key = make_domain_meta_key(self._name)
206
+ raw = self._redis.hget(meta_key, "default_ttl_seconds")
207
+ if raw:
208
+ return int(raw)
209
+ return self._config.default_ttl_seconds
210
+
211
+ def get_default_lock_ttl_seconds(self) -> int:
212
+ """Effective ``default_lock_ttl_seconds`` for this domain."""
213
+ meta_key = make_domain_meta_key(self._name)
214
+ raw = self._redis.hget(meta_key, "default_lock_ttl_seconds")
215
+ return int(raw) if raw else self._config.default_lock_ttl_seconds
216
+
217
+ def get_default_lock_acquire_timeout_seconds(self) -> int:
218
+ """Effective ``default_lock_acquire_timeout_seconds`` for this domain."""
219
+ meta_key = make_domain_meta_key(self._name)
220
+ raw = self._redis.hget(meta_key, "default_lock_acquire_timeout_seconds")
221
+ return int(raw) if raw else self._config.default_lock_acquire_timeout_seconds
222
+
223
+ def get_gc_lock_ttl_seconds(self) -> int:
224
+ """Effective ``gc_lock_ttl_seconds`` for this domain."""
225
+ meta_key = make_domain_meta_key(self._name)
226
+ raw = self._redis.hget(meta_key, "gc_lock_ttl_seconds")
227
+ return int(raw) if raw else self._config.gc_lock_ttl_seconds
228
+
229
+ def get_gc_lock_wait_seconds(self) -> int:
230
+ """Effective ``gc_lock_wait_seconds`` for this domain."""
231
+ meta_key = make_domain_meta_key(self._name)
232
+ raw = self._redis.hget(meta_key, "gc_lock_wait_seconds")
233
+ return int(raw) if raw else self._config.gc_lock_wait_seconds
234
+
235
+ def _start_gc(self) -> None:
236
+ cfg = self._config
237
+ if not cfg.gc_enabled:
238
+ return
239
+ self._gc_thread = threading.Thread(target=self._gc_loop, daemon=True, name=f"gpdc-gc-{self._name}")
240
+ self._gc_thread.start()
241
+
242
+ def _gc_loop(self) -> None:
243
+ from .gc import run_gc_for_namespace
244
+
245
+ interval = self._config.gc_interval_seconds
246
+ while not self._gc_stop_event.wait(timeout=interval):
247
+ try:
248
+ for ns_name in self:
249
+ run_gc_for_namespace(self._redis, self._name, ns_name)
250
+ except Exception as e:
251
+ logger.warning("GPDC GC error in domain %s: %s", self._name, e)
252
+
253
+ def run_gc(self, namespace: str | None = None) -> int:
254
+ """Run GC manually. If namespace is None, run for all namespaces."""
255
+ self._check_open()
256
+ from .gc import run_gc_for_namespace
257
+
258
+ total = 0
259
+ if namespace:
260
+ total += run_gc_for_namespace(self._redis, self._name, namespace)
261
+ else:
262
+ for ns_name in self:
263
+ total += run_gc_for_namespace(self._redis, self._name, ns_name)
264
+ return total
265
+
266
+ def delete_namespace(self, name: str) -> None:
267
+ self._check_open()
268
+ ns_key = make_domain_namespaces_key(self._name)
269
+ if not self._redis.sismember(ns_key, name):
270
+ raise KeyError(name)
271
+ pattern = f"gpdc:{self._name}:{name}:*"
272
+ cursor = 0
273
+ while True:
274
+ cursor, keys = self._redis.scan(cursor=cursor, match=pattern, count=100)
275
+ if keys:
276
+ self._redis.delete(*keys)
277
+ if cursor == 0:
278
+ break
279
+ self._redis.delete(make_namespace_meta_key(self._name, name))
280
+ self._redis.srem(ns_key, name)
281
+ self._namespaces.pop(name, None)
282
+ logger.info("Deleted namespace %r from domain %r", name, self._name)
283
+
284
+ def destroy(self) -> None:
285
+ self._check_open()
286
+ ns_key = make_domain_namespaces_key(self._name)
287
+ for ns_name in list(self._redis.smembers(ns_key)):
288
+ self.delete_namespace(ns_name)
289
+ self._redis.delete(make_domain_meta_key(self._name))
290
+ self._redis.delete(ns_key)
291
+ self._redis.srem(GLOBAL_DOMAINS_KEY, self._name)
292
+ self._namespaces.clear()
293
+ logger.info("Destroyed domain %r", self._name)
294
+ self.close()
295
+
296
+ def close(self) -> None:
297
+ if self._closed:
298
+ return
299
+ self._closed = True
300
+ self._gc_stop_event.set()
301
+ if self._gc_thread and self._gc_thread.is_alive():
302
+ self._gc_thread.join(timeout=5)
303
+ self._gc_thread = None
304
+ if self._redis is not None:
305
+ self._redis.close()
306
+ self._redis = None
307
+
308
+ def __enter__(self):
309
+ return self
310
+
311
+ def __exit__(self, exc_type, exc_val, exc_tb):
312
+ self.close()
313
+ return False
314
+
315
+ def __del__(self):
316
+ # __del__ must never raise: during interpreter shutdown module globals
317
+ # (incl. `logger`) may have been nulled, and an exception here only adds
318
+ # "Exception ignored" noise. Swallow defensively so auto-close stays silent.
319
+ try:
320
+ if not getattr(self, "_closed", True):
321
+ logger.warning("GPDCDomain %r was not explicitly closed; auto-closing in __del__", self._name)
322
+ self.close()
323
+ except Exception:
324
+ pass
325
+
326
+
327
+ def reset_domain_config(name: str, config: "GPDCDomainConfig") -> None:
328
+ """Delete the four lock-config fields from the domain's meta hash in Redis.
329
+
330
+ Intended to be called BEFORE constructing ``GPDCDomain`` when the user
331
+ wants to change lock config on an existing domain:
332
+
333
+ reset_domain_config("mydomain", old_config)
334
+ domain = GPDCDomain("mydomain", new_config)
335
+
336
+ No-op if the meta hash has no lock fields (HDEL on missing fields).
337
+
338
+ Args:
339
+ name: Domain name whose meta should be cleared.
340
+ config: ``GPDCDomainConfig`` carrying Redis connection parameters.
341
+ Only connection fields are read; lock fields on this config are
342
+ ignored.
343
+ """
344
+ pool = redis.ConnectionPool(
345
+ host=config.redis_host,
346
+ port=config.redis_port,
347
+ db=config.redis_db,
348
+ password=config.redis_password or None,
349
+ decode_responses=True,
350
+ )
351
+ r = redis.Redis(connection_pool=pool)
352
+ try:
353
+ meta_key = make_domain_meta_key(name)
354
+ r.hdel(
355
+ meta_key,
356
+ "default_lock_ttl_seconds",
357
+ "default_lock_acquire_timeout_seconds",
358
+ "gc_lock_ttl_seconds",
359
+ "gc_lock_wait_seconds",
360
+ )
361
+ finally:
362
+ r.close()
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class GPDCError(Exception):
5
+ """Base exception for all gpdatacached errors."""
6
+
7
+
8
+ class ObjectGoneError(GPDCError):
9
+ def __init__(self, object_uri: str) -> None:
10
+ self.object_uri = object_uri
11
+ super().__init__(f"Object {object_uri!r} no longer exists in Redis")
12
+
13
+
14
+ class CanonicalObjectReferencedError(GPDCError):
15
+ def __init__(
16
+ self,
17
+ canonical_name: str,
18
+ canonical_uri: str,
19
+ referrers: list[str],
20
+ ) -> None:
21
+ self.canonical_name = canonical_name
22
+ self.canonical_uri = canonical_uri
23
+ self.referrers = referrers
24
+ names = ", ".join(repr(r) for r in referrers)
25
+ super().__init__(
26
+ f"Cannot delete canonical object {canonical_name!r} ({canonical_uri}) "
27
+ f"because it is still referenced by: [{names}]"
28
+ )
29
+
30
+
31
+ class LifecyclePermissionError(GPDCError):
32
+ """Raised when attempting to modify lifecycle of an object not owned by the caller."""
33
+
34
+ def __init__(self, canonical_name: str, owner_name: str) -> None:
35
+ self.canonical_name = canonical_name
36
+ self.owner_name = owner_name
37
+ super().__init__(
38
+ f"Cannot modify lifecycle of {canonical_name!r}: "
39
+ f"owned by {owner_name!r}. "
40
+ f"Call take_ownership() first to gain lifecycle management rights."
41
+ )
42
+
43
+
44
+ class CyclicReferenceError(GPDCError):
45
+ """Raised when a cyclic container reference is detected during encoding."""
46
+
47
+ def __init__(self, type_name: str) -> None:
48
+ self.type_name = type_name
49
+ super().__init__(
50
+ f"Cannot encode cyclic reference involving {type_name!r}: "
51
+ f"a container references itself directly or transitively. "
52
+ f"Break the cycle before storing, or store the shared container "
53
+ f"separately and reference it by name."
54
+ )
55
+
56
+
57
+ class GPDCLockError(GPDCError):
58
+ """Base class for all lock-acquisition/ownership errors."""
59
+
60
+
61
+ class LockAcquisitionTimeout(GPDCLockError):
62
+ """Raised when ``obj.lock()`` cannot acquire the lock within ``timeout``."""
63
+
64
+ def __init__(self, lock_key: str, timeout: float) -> None:
65
+ self.lock_key = lock_key
66
+ self.timeout = timeout
67
+ super().__init__(f"Acquiring lock {lock_key!r} timed out after {timeout}s")
68
+
69
+
70
+ class LockOwnershipError(GPDCLockError):
71
+ """Raised when releasing a lock whose Redis token no longer matches.
72
+
73
+ Internal: ``HeldLock.release()`` swallows this case (logs a warning) instead
74
+ of raising, because the caller has no recovery path. The exception class is
75
+ kept for callers that wrap release logic themselves.
76
+ """
77
+
78
+
79
+ class GPDCLockConfigInconsistentError(GPDCError):
80
+ """Raised at GPDCDomain construction when the lock config in the explicit
81
+ ``GPDCDomainConfig`` conflicts with values already stored in Redis domain
82
+ meta.
83
+
84
+ Note: extends ``GPDCError`` directly (not ``GPDCLockError``) because this
85
+ is a configuration error surfaced before any lock acquisition is attempted.
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ domain: str,
91
+ conflicts: dict[str, tuple[str, str]],
92
+ legal_values: dict[str, str],
93
+ ) -> None:
94
+ self.domain = domain
95
+ self.conflicts = conflicts
96
+ self.legal_values = legal_values
97
+
98
+ lines = [f"Lock config for domain {domain!r} inconsistent with Redis:"]
99
+ for field, (redis_val, config_val) in conflicts.items():
100
+ lines.append(f" conflict: {field}: redis={redis_val!r}, your config={config_val!r}")
101
+ lines.append("Legal values currently in Redis domain meta:")
102
+ if legal_values:
103
+ for field, val in legal_values.items():
104
+ lines.append(f" {field} = {val}")
105
+ else:
106
+ lines.append(" (none)")
107
+ lines.append(
108
+ "To change these values, call "
109
+ "gpdatacached.reset_domain_config(name, config) "
110
+ "before constructing GPDCDomain."
111
+ )
112
+ super().__init__("\n".join(lines))
File without changes