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/__init__.py +79 -0
- gpdatacached/builtins/__init__.py +3 -0
- gpdatacached/builtins/bool_object.py +25 -0
- gpdatacached/builtins/datetime_object.py +72 -0
- gpdatacached/builtins/dict_object.py +195 -0
- gpdatacached/builtins/float_object.py +26 -0
- gpdatacached/builtins/int_object.py +26 -0
- gpdatacached/builtins/list_object.py +232 -0
- gpdatacached/builtins/none_object.py +25 -0
- gpdatacached/builtins/registry.py +110 -0
- gpdatacached/builtins/set_object.py +161 -0
- gpdatacached/builtins/str_object.py +23 -0
- gpdatacached/builtins/tuple_object.py +49 -0
- gpdatacached/codec.py +47 -0
- gpdatacached/config.py +101 -0
- gpdatacached/container.py +205 -0
- gpdatacached/domain.py +362 -0
- gpdatacached/exceptions.py +112 -0
- gpdatacached/extensions/__init__.py +0 -0
- gpdatacached/extensions/pandas_support.py +1635 -0
- gpdatacached/extensions/pydantic_support.py +169 -0
- gpdatacached/gc.py +282 -0
- gpdatacached/keys.py +150 -0
- gpdatacached/lifecycle.py +171 -0
- gpdatacached/locking.py +163 -0
- gpdatacached/meta.py +126 -0
- gpdatacached/namespace.py +602 -0
- gpdatacached/object.py +318 -0
- gpdatacached/refset.py +54 -0
- gpdatacached/scalar.py +56 -0
- gpdatacached/types.py +296 -0
- gpdatacached-0.1.0.dist-info/METADATA +171 -0
- gpdatacached-0.1.0.dist-info/RECORD +35 -0
- gpdatacached-0.1.0.dist-info/WHEEL +4 -0
- gpdatacached-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""GPDC namespace: a MutableMapping-compatible logical isolation space for data objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
from collections.abc import MutableMapping
|
|
7
|
+
from typing import TYPE_CHECKING, NamedTuple, Iterator
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .config import GPDCNamespaceConfig
|
|
11
|
+
from .domain import GPDCDomain
|
|
12
|
+
|
|
13
|
+
from .keys import (
|
|
14
|
+
collect_ref_names_from_raw,
|
|
15
|
+
make_domain_namespaces_key,
|
|
16
|
+
make_namespace_meta_key,
|
|
17
|
+
make_object_key,
|
|
18
|
+
make_object_key_prefix,
|
|
19
|
+
make_raw_key,
|
|
20
|
+
make_ref_key,
|
|
21
|
+
make_uri,
|
|
22
|
+
validate_namespace_name,
|
|
23
|
+
validate_object_name,
|
|
24
|
+
)
|
|
25
|
+
from .meta import ObjectMeta
|
|
26
|
+
from .refset import add_referrer, release_ref, remove_referrer, get_referrers, has_referrers
|
|
27
|
+
from .types import GPDCTypeRegistry
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BindingResolution(NamedTuple):
|
|
31
|
+
binding_name: str
|
|
32
|
+
canonical_name: str
|
|
33
|
+
binding_kind: str
|
|
34
|
+
binding_obj_key: str
|
|
35
|
+
canonical_obj_key: str
|
|
36
|
+
canonical_raw_key: str
|
|
37
|
+
meta: ObjectMeta
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GPDCNamespace(MutableMapping):
|
|
41
|
+
"""Logical isolation space within a GPDCDomain.
|
|
42
|
+
|
|
43
|
+
Implements the full ``MutableMapping`` interface backed by Redis.
|
|
44
|
+
Keys are named object names; ``__getitem__`` returns Python native values.
|
|
45
|
+
Use ``get_object()`` to retrieve ``GPDCDataObject`` instances (read-only).
|
|
46
|
+
Use ``ensure_object(key, default)`` to get an object or create it with a default value.
|
|
47
|
+
Anonymous objects (names starting with ``~``) are stored in the same
|
|
48
|
+
Redis key space but are excluded from all public MutableMapping methods
|
|
49
|
+
(``__iter__``, ``__len__``, ``__contains__``).
|
|
50
|
+
|
|
51
|
+
**Design constraint:** Cross-namespace container references are not
|
|
52
|
+
supported. All ref bindings point to objects within the same namespace.
|
|
53
|
+
This allows ``clear()`` to safely delete all keys in bulk without
|
|
54
|
+
per-object referrer cleanup.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
name: str,
|
|
60
|
+
domain: "GPDCDomain",
|
|
61
|
+
*,
|
|
62
|
+
config: "GPDCNamespaceConfig | None" = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
validate_namespace_name(name)
|
|
65
|
+
self._name = name
|
|
66
|
+
self._domain = domain
|
|
67
|
+
self._ns_config = config
|
|
68
|
+
self._init_namespace_meta()
|
|
69
|
+
|
|
70
|
+
def _init_namespace_meta(self) -> None:
|
|
71
|
+
"""Write namespace metadata to Redis if not present.
|
|
72
|
+
|
|
73
|
+
Config priority for new namespaces (metadata not yet in Redis):
|
|
74
|
+
1. Fields explicitly set in the ``config`` argument passed to ``__init__``
|
|
75
|
+
2. Domain-level defaults
|
|
76
|
+
|
|
77
|
+
If metadata already exists in Redis it is left untouched; only the
|
|
78
|
+
namespace-set registration (``sadd``) is repeated (idempotent).
|
|
79
|
+
"""
|
|
80
|
+
meta_key = make_namespace_meta_key(self._domain.name, self._name)
|
|
81
|
+
existing = self._domain.redis.hgetall(meta_key)
|
|
82
|
+
if not existing:
|
|
83
|
+
ns_cfg = self._ns_config
|
|
84
|
+
if ns_cfg is not None and ns_cfg.default_cache_mode is not None:
|
|
85
|
+
cache_mode = ns_cfg.default_cache_mode
|
|
86
|
+
else:
|
|
87
|
+
cache_mode = self._domain.get_default_cache_mode()
|
|
88
|
+
|
|
89
|
+
if ns_cfg is not None and ns_cfg.default_ttl_seconds is not None:
|
|
90
|
+
ttl = ns_cfg.default_ttl_seconds
|
|
91
|
+
else:
|
|
92
|
+
ttl = self._domain.get_default_ttl_seconds()
|
|
93
|
+
|
|
94
|
+
self._domain.redis.hset(
|
|
95
|
+
meta_key,
|
|
96
|
+
mapping={
|
|
97
|
+
"default_cache_mode": cache_mode,
|
|
98
|
+
"default_ttl_seconds": str(ttl),
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
# Always register this namespace in the domain's namespace set
|
|
102
|
+
self._domain.redis.sadd(make_domain_namespaces_key(self._domain.name), self._name)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def name(self) -> str:
|
|
106
|
+
return self._name
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def domain(self) -> "GPDCDomain":
|
|
110
|
+
return self._domain
|
|
111
|
+
|
|
112
|
+
def _redis(self):
|
|
113
|
+
return self._domain.redis
|
|
114
|
+
|
|
115
|
+
def __repr__(self) -> str:
|
|
116
|
+
return f"GPDCNamespace({self._name!r}, domain={self._domain.name!r})"
|
|
117
|
+
|
|
118
|
+
def _get_default_cache_mode(self) -> str:
|
|
119
|
+
meta_key = make_namespace_meta_key(self._domain.name, self._name)
|
|
120
|
+
val = self._redis().hget(meta_key, "default_cache_mode")
|
|
121
|
+
return val or self._domain.get_default_cache_mode()
|
|
122
|
+
|
|
123
|
+
def _get_default_ttl_seconds(self) -> int:
|
|
124
|
+
meta_key = make_namespace_meta_key(self._domain.name, self._name)
|
|
125
|
+
raw = self._redis().hget(meta_key, "default_ttl_seconds")
|
|
126
|
+
return int(raw) if raw else self._domain.get_default_ttl_seconds()
|
|
127
|
+
|
|
128
|
+
# ── Dict-like interface ──────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def _resolve_binding(self, key: str) -> BindingResolution | None:
|
|
131
|
+
"""Resolve a namespace binding key to its canonical owner."""
|
|
132
|
+
r = self._redis()
|
|
133
|
+
obj_key = make_object_key(self._domain.name, self._name, key)
|
|
134
|
+
key_type = r.type(obj_key)
|
|
135
|
+
|
|
136
|
+
if key_type == "hash":
|
|
137
|
+
meta = ObjectMeta.load(r, obj_key)
|
|
138
|
+
if meta is None:
|
|
139
|
+
return None
|
|
140
|
+
raw_key = meta.value if meta.is_collection() else ""
|
|
141
|
+
return BindingResolution(
|
|
142
|
+
binding_name=key,
|
|
143
|
+
canonical_name=key,
|
|
144
|
+
binding_kind="owner",
|
|
145
|
+
binding_obj_key=obj_key,
|
|
146
|
+
canonical_obj_key=obj_key,
|
|
147
|
+
canonical_raw_key=raw_key,
|
|
148
|
+
meta=meta,
|
|
149
|
+
)
|
|
150
|
+
elif key_type == "string":
|
|
151
|
+
ref_value = r.get(obj_key)
|
|
152
|
+
if not ref_value or not ref_value.startswith("ref:"):
|
|
153
|
+
return None
|
|
154
|
+
canonical_obj_key = ref_value[len("ref:") :]
|
|
155
|
+
obj_prefix = make_object_key_prefix(self._domain.name, self._name)
|
|
156
|
+
if not canonical_obj_key.startswith(obj_prefix):
|
|
157
|
+
return None
|
|
158
|
+
canonical_key_type = r.type(canonical_obj_key)
|
|
159
|
+
if canonical_key_type != "hash":
|
|
160
|
+
return None
|
|
161
|
+
canonical_name = canonical_obj_key[len(obj_prefix) :]
|
|
162
|
+
canonical_meta = ObjectMeta.load(r, canonical_obj_key)
|
|
163
|
+
if canonical_meta is None:
|
|
164
|
+
return None
|
|
165
|
+
raw_key = canonical_meta.value if canonical_meta.is_collection() else ""
|
|
166
|
+
return BindingResolution(
|
|
167
|
+
binding_name=key,
|
|
168
|
+
canonical_name=canonical_name,
|
|
169
|
+
binding_kind="reference",
|
|
170
|
+
binding_obj_key=obj_key,
|
|
171
|
+
canonical_obj_key=canonical_obj_key,
|
|
172
|
+
canonical_raw_key=raw_key,
|
|
173
|
+
meta=canonical_meta,
|
|
174
|
+
)
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
def _cleanup_alias_binding(self, key: str, existing: BindingResolution) -> None:
|
|
178
|
+
"""Remove an alias string binding and clean up its ref set entry."""
|
|
179
|
+
r = self._redis()
|
|
180
|
+
canonical_name = existing.canonical_name
|
|
181
|
+
ref_key = make_ref_key(self._domain.name, self._name, canonical_name)
|
|
182
|
+
release_ref(r, ref_key, key)
|
|
183
|
+
r.delete(existing.binding_obj_key)
|
|
184
|
+
if canonical_name.startswith("~") and not has_referrers(r, ref_key):
|
|
185
|
+
self._cascade_delete_anonymous(canonical_name)
|
|
186
|
+
|
|
187
|
+
def _cleanup_existing_for_overwrite(self, key: str, existing: BindingResolution) -> None:
|
|
188
|
+
"""Clean up an existing binding before it is overwritten with a container alias."""
|
|
189
|
+
from .exceptions import CanonicalObjectReferencedError
|
|
190
|
+
|
|
191
|
+
r = self._redis()
|
|
192
|
+
if existing.binding_kind == "reference":
|
|
193
|
+
self._cleanup_alias_binding(key, existing)
|
|
194
|
+
elif existing.binding_kind == "owner" and existing.meta.type.startswith("collection:"):
|
|
195
|
+
ref_key = make_ref_key(self._domain.name, self._name, key)
|
|
196
|
+
referrers = get_referrers(r, ref_key)
|
|
197
|
+
if referrers:
|
|
198
|
+
raise CanonicalObjectReferencedError(
|
|
199
|
+
canonical_name=key,
|
|
200
|
+
canonical_uri=make_uri(self._domain.name, self._name, key),
|
|
201
|
+
referrers=sorted(referrers),
|
|
202
|
+
)
|
|
203
|
+
self._teardown_owner_container(key, existing.meta)
|
|
204
|
+
r.delete(existing.binding_obj_key)
|
|
205
|
+
else:
|
|
206
|
+
r.delete(existing.binding_obj_key)
|
|
207
|
+
|
|
208
|
+
def _teardown_owner_container(self, key: str, meta: ObjectMeta) -> None:
|
|
209
|
+
"""Remove an owner container's raw data, anonymous children, and ref sets."""
|
|
210
|
+
r = self._redis()
|
|
211
|
+
stored_raw_key = meta.value
|
|
212
|
+
obj_type = meta.type
|
|
213
|
+
ref_key = make_ref_key(self._domain.name, self._name, key)
|
|
214
|
+
|
|
215
|
+
if stored_raw_key and obj_type.startswith("collection:"):
|
|
216
|
+
nested_ref_names = collect_ref_names_from_raw(r, stored_raw_key, obj_type)
|
|
217
|
+
for nested_name in nested_ref_names:
|
|
218
|
+
nested_ref_key = make_ref_key(self._domain.name, self._name, nested_name)
|
|
219
|
+
remove_referrer(r, nested_ref_key, key)
|
|
220
|
+
if nested_name.startswith("~"):
|
|
221
|
+
self._cascade_delete_anonymous(nested_name)
|
|
222
|
+
|
|
223
|
+
if stored_raw_key:
|
|
224
|
+
r.delete(stored_raw_key)
|
|
225
|
+
r.delete(ref_key)
|
|
226
|
+
|
|
227
|
+
def _write_scalar_meta(
|
|
228
|
+
self,
|
|
229
|
+
obj_key: str,
|
|
230
|
+
entry,
|
|
231
|
+
encoded_value: str,
|
|
232
|
+
policy,
|
|
233
|
+
now: datetime.datetime,
|
|
234
|
+
*,
|
|
235
|
+
is_new: bool,
|
|
236
|
+
obj_name: str = "",
|
|
237
|
+
) -> None:
|
|
238
|
+
from .lifecycle import apply_ttl
|
|
239
|
+
|
|
240
|
+
r = self._redis()
|
|
241
|
+
meta = ObjectMeta(
|
|
242
|
+
type=entry.gpdc_type,
|
|
243
|
+
value=encoded_value,
|
|
244
|
+
cache_mode=policy.cache_mode,
|
|
245
|
+
ttl=policy.declared_ttl,
|
|
246
|
+
owner=obj_name,
|
|
247
|
+
created_at=now if is_new else None,
|
|
248
|
+
updated_at=now,
|
|
249
|
+
)
|
|
250
|
+
meta.save(r, obj_key)
|
|
251
|
+
apply_ttl(r, [obj_key], policy)
|
|
252
|
+
|
|
253
|
+
def _write_container_meta(
|
|
254
|
+
self,
|
|
255
|
+
obj_key: str,
|
|
256
|
+
entry,
|
|
257
|
+
raw_key: str,
|
|
258
|
+
policy,
|
|
259
|
+
now: datetime.datetime,
|
|
260
|
+
*,
|
|
261
|
+
is_new: bool,
|
|
262
|
+
item_count: int = 0,
|
|
263
|
+
obj_name: str = "",
|
|
264
|
+
) -> None:
|
|
265
|
+
r = self._redis()
|
|
266
|
+
meta = ObjectMeta(
|
|
267
|
+
type=entry.gpdc_type,
|
|
268
|
+
value=raw_key,
|
|
269
|
+
cache_mode=policy.cache_mode,
|
|
270
|
+
ttl=policy.declared_ttl,
|
|
271
|
+
owner=obj_name,
|
|
272
|
+
created_at=now if is_new else None,
|
|
273
|
+
updated_at=now,
|
|
274
|
+
item_count=item_count,
|
|
275
|
+
)
|
|
276
|
+
meta.save(r, obj_key)
|
|
277
|
+
|
|
278
|
+
def _setitem_container_ref(self, key: str, container) -> None:
|
|
279
|
+
from .container import GPDCContainerObject
|
|
280
|
+
|
|
281
|
+
assert isinstance(container, GPDCContainerObject)
|
|
282
|
+
if container._namespace is not self:
|
|
283
|
+
raise ValueError("Cross-namespace container reference sharing is not supported")
|
|
284
|
+
|
|
285
|
+
r = self._redis()
|
|
286
|
+
canonical_name = container.canonical_name
|
|
287
|
+
canonical_obj_key = make_object_key(self._domain.name, self._name, canonical_name)
|
|
288
|
+
|
|
289
|
+
alias_obj_key = make_object_key(self._domain.name, self._name, key)
|
|
290
|
+
r.set(alias_obj_key, f"ref:{canonical_obj_key}")
|
|
291
|
+
|
|
292
|
+
ref_key = make_ref_key(self._domain.name, self._name, canonical_name)
|
|
293
|
+
add_referrer(r, ref_key, key)
|
|
294
|
+
|
|
295
|
+
def __setitem__(self, key: str, value: object) -> None:
|
|
296
|
+
validate_object_name(key)
|
|
297
|
+
from .container import GPDCContainerObject
|
|
298
|
+
from .scalar import GPDCScalarObject
|
|
299
|
+
from .codec import GPDCContainerCodec
|
|
300
|
+
from .exceptions import CanonicalObjectReferencedError
|
|
301
|
+
|
|
302
|
+
r = self._redis()
|
|
303
|
+
obj_key = make_object_key(self._domain.name, self._name, key)
|
|
304
|
+
|
|
305
|
+
existing = self._resolve_binding(key)
|
|
306
|
+
|
|
307
|
+
if isinstance(value, GPDCContainerObject):
|
|
308
|
+
if value._namespace is not self:
|
|
309
|
+
raise ValueError("Cross-namespace container reference sharing is not supported")
|
|
310
|
+
if existing and existing.canonical_name == value.canonical_name:
|
|
311
|
+
return
|
|
312
|
+
if existing:
|
|
313
|
+
self._cleanup_existing_for_overwrite(key, existing)
|
|
314
|
+
self._setitem_container_ref(key, value)
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
if isinstance(value, GPDCScalarObject):
|
|
318
|
+
value = value.value
|
|
319
|
+
|
|
320
|
+
entry = GPDCTypeRegistry.lookup_by_python_type(value)
|
|
321
|
+
now = datetime.datetime.now(datetime.UTC)
|
|
322
|
+
|
|
323
|
+
is_new = True
|
|
324
|
+
policy = None
|
|
325
|
+
|
|
326
|
+
if existing:
|
|
327
|
+
if existing.binding_kind == "owner" and existing.meta.type.startswith("collection:"):
|
|
328
|
+
ref_key = make_ref_key(self._domain.name, self._name, key)
|
|
329
|
+
referrers = get_referrers(r, ref_key)
|
|
330
|
+
if referrers:
|
|
331
|
+
raise CanonicalObjectReferencedError(
|
|
332
|
+
canonical_name=key,
|
|
333
|
+
canonical_uri=make_uri(self._domain.name, self._name, key),
|
|
334
|
+
referrers=sorted(referrers),
|
|
335
|
+
)
|
|
336
|
+
self._teardown_owner_container(key, existing.meta)
|
|
337
|
+
r.delete(obj_key)
|
|
338
|
+
elif existing.binding_kind == "reference":
|
|
339
|
+
self._cleanup_alias_binding(key, existing)
|
|
340
|
+
elif existing.binding_kind == "owner" and not entry.is_container:
|
|
341
|
+
# Design intent (docs/lifecycle.md "Overwrite caveat"): only
|
|
342
|
+
# scalar-overwrites-scalar preserves the previous policy. When the
|
|
343
|
+
# new value is a container, fall through to the default-policy path
|
|
344
|
+
# below — the scalar owner binding is torn down and re-created like
|
|
345
|
+
# any container overwrite.
|
|
346
|
+
is_new = False
|
|
347
|
+
from .lifecycle import policy_from_meta
|
|
348
|
+
|
|
349
|
+
policy = policy_from_meta(existing.meta)
|
|
350
|
+
|
|
351
|
+
if policy is None:
|
|
352
|
+
from .lifecycle import policy_from_defaults
|
|
353
|
+
|
|
354
|
+
policy = policy_from_defaults(self._get_default_cache_mode(), self._get_default_ttl_seconds())
|
|
355
|
+
|
|
356
|
+
if entry.is_container:
|
|
357
|
+
raw_key = make_raw_key(self._domain.name, self._name, key)
|
|
358
|
+
try:
|
|
359
|
+
count = len(value)
|
|
360
|
+
except TypeError:
|
|
361
|
+
model_fields = getattr(type(value), "model_fields", None)
|
|
362
|
+
if model_fields is not None:
|
|
363
|
+
count = len(model_fields)
|
|
364
|
+
else:
|
|
365
|
+
count = 0
|
|
366
|
+
self._write_container_meta(
|
|
367
|
+
obj_key, entry, raw_key, policy, now, is_new=is_new, item_count=count, obj_name=key
|
|
368
|
+
)
|
|
369
|
+
if entry.codec_class and issubclass(entry.codec_class, GPDCContainerCodec):
|
|
370
|
+
if not is_new:
|
|
371
|
+
entry.codec_class.delete_raw(self, raw_key)
|
|
372
|
+
from .types import encode_session
|
|
373
|
+
|
|
374
|
+
with encode_session(self):
|
|
375
|
+
entry.codec_class.create_raw(self, raw_key, value, key, policy.cache_mode, policy.declared_ttl)
|
|
376
|
+
from .lifecycle import apply_ttl
|
|
377
|
+
|
|
378
|
+
ref_key = make_ref_key(self._domain.name, self._name, key)
|
|
379
|
+
apply_ttl(r, [obj_key, raw_key, ref_key], policy)
|
|
380
|
+
else:
|
|
381
|
+
codec = entry.codec_class
|
|
382
|
+
encoded = codec.encode_value(value)
|
|
383
|
+
self._write_scalar_meta(obj_key, entry, encoded, policy, now, is_new=is_new, obj_name=key)
|
|
384
|
+
|
|
385
|
+
def _get_object(self, key: str):
|
|
386
|
+
validate_object_name(key)
|
|
387
|
+
from .container import GPDCContainerObject
|
|
388
|
+
|
|
389
|
+
resolution = self._resolve_binding(key)
|
|
390
|
+
if not resolution:
|
|
391
|
+
raise KeyError(f"{key!r} not found in namespace {self._name!r}")
|
|
392
|
+
|
|
393
|
+
type_str = resolution.meta.type
|
|
394
|
+
entry = GPDCTypeRegistry.lookup_by_gpdc_type(type_str)
|
|
395
|
+
|
|
396
|
+
canonical_name = resolution.canonical_name
|
|
397
|
+
is_ref = resolution.binding_kind == "reference"
|
|
398
|
+
|
|
399
|
+
obj = entry.object_class(key, self, canonical_name=canonical_name if is_ref else None)
|
|
400
|
+
|
|
401
|
+
if isinstance(obj, GPDCContainerObject):
|
|
402
|
+
stored_raw_key = resolution.canonical_raw_key
|
|
403
|
+
if stored_raw_key:
|
|
404
|
+
obj.raw_key = stored_raw_key
|
|
405
|
+
if resolution.binding_kind == "reference":
|
|
406
|
+
canonical_ref_key = make_ref_key(self._domain.name, self._name, resolution.canonical_name)
|
|
407
|
+
obj.ref_key = canonical_ref_key
|
|
408
|
+
|
|
409
|
+
return obj
|
|
410
|
+
|
|
411
|
+
def __getitem__(self, key: str):
|
|
412
|
+
from .container import GPDCContainerObject
|
|
413
|
+
|
|
414
|
+
# by design: 标量返回原生值(ns["count"] → 42),容器返回对象本身。
|
|
415
|
+
# 容器必须返回对象,否则用户无法调用 append/pop 等方法操作 Redis。
|
|
416
|
+
obj = self._get_object(key)
|
|
417
|
+
if isinstance(obj, GPDCContainerObject):
|
|
418
|
+
return obj
|
|
419
|
+
return obj.value
|
|
420
|
+
|
|
421
|
+
def get_object(self, key: str):
|
|
422
|
+
return self._get_object(key)
|
|
423
|
+
|
|
424
|
+
def ensure_object(self, key: str, default):
|
|
425
|
+
try:
|
|
426
|
+
return self._get_object(key)
|
|
427
|
+
except KeyError:
|
|
428
|
+
self[key] = default
|
|
429
|
+
return self._get_object(key)
|
|
430
|
+
|
|
431
|
+
def __delitem__(self, key: str) -> None:
|
|
432
|
+
validate_object_name(key)
|
|
433
|
+
from .exceptions import CanonicalObjectReferencedError
|
|
434
|
+
|
|
435
|
+
r = self._redis()
|
|
436
|
+
resolution = self._resolve_binding(key)
|
|
437
|
+
if not resolution:
|
|
438
|
+
raise KeyError(f"{key!r} not found in namespace {self._name!r}")
|
|
439
|
+
|
|
440
|
+
obj_key = resolution.binding_obj_key
|
|
441
|
+
|
|
442
|
+
if resolution.binding_kind == "reference":
|
|
443
|
+
canonical_name = resolution.canonical_name
|
|
444
|
+
ref_key = make_ref_key(self._domain.name, self._name, canonical_name)
|
|
445
|
+
release_ref(r, ref_key, key)
|
|
446
|
+
r.delete(obj_key)
|
|
447
|
+
if canonical_name.startswith("~") and not has_referrers(r, ref_key):
|
|
448
|
+
self._cascade_delete_anonymous(canonical_name)
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
obj_type = resolution.meta.type
|
|
452
|
+
|
|
453
|
+
if not obj_type.startswith("collection:"):
|
|
454
|
+
r.delete(obj_key)
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
ref_key = make_ref_key(self._domain.name, self._name, key)
|
|
458
|
+
referrers = get_referrers(r, ref_key)
|
|
459
|
+
if referrers:
|
|
460
|
+
raise CanonicalObjectReferencedError(
|
|
461
|
+
canonical_name=key,
|
|
462
|
+
canonical_uri=make_uri(self._domain.name, self._name, key),
|
|
463
|
+
referrers=sorted(referrers),
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
self._teardown_owner_container(key, resolution.meta)
|
|
467
|
+
r.delete(obj_key)
|
|
468
|
+
|
|
469
|
+
def _cascade_delete_anonymous(self, anon_name: str) -> None:
|
|
470
|
+
r = self._redis()
|
|
471
|
+
obj_key = make_object_key(self._domain.name, self._name, anon_name)
|
|
472
|
+
meta = ObjectMeta.load(r, obj_key)
|
|
473
|
+
if meta is None:
|
|
474
|
+
return
|
|
475
|
+
|
|
476
|
+
raw_key = meta.value
|
|
477
|
+
ref_key = make_ref_key(self._domain.name, self._name, anon_name)
|
|
478
|
+
|
|
479
|
+
referrers = get_referrers(r, ref_key)
|
|
480
|
+
if referrers:
|
|
481
|
+
return
|
|
482
|
+
|
|
483
|
+
if raw_key and meta.is_collection():
|
|
484
|
+
nested_ref_names = collect_ref_names_from_raw(r, raw_key, meta.type)
|
|
485
|
+
r.delete(raw_key)
|
|
486
|
+
|
|
487
|
+
for nested in nested_ref_names:
|
|
488
|
+
nested_ref_key = make_ref_key(self._domain.name, self._name, nested)
|
|
489
|
+
remove_referrer(r, nested_ref_key, anon_name)
|
|
490
|
+
if nested.startswith("~"):
|
|
491
|
+
self._cascade_delete_anonymous(nested)
|
|
492
|
+
|
|
493
|
+
r.delete(ref_key)
|
|
494
|
+
r.delete(obj_key)
|
|
495
|
+
|
|
496
|
+
def __contains__(self, key: object) -> bool:
|
|
497
|
+
if not isinstance(key, str):
|
|
498
|
+
return False
|
|
499
|
+
if key.startswith("~"):
|
|
500
|
+
return False
|
|
501
|
+
return self._resolve_binding(key) is not None
|
|
502
|
+
|
|
503
|
+
def __iter__(self) -> Iterator[str]:
|
|
504
|
+
"""Iterate over named object names using SCAN (no KEYS).
|
|
505
|
+
|
|
506
|
+
Anonymous objects (names starting with ``~``) are excluded.
|
|
507
|
+
Stale aliases whose canonical owner no longer exists are excluded.
|
|
508
|
+
|
|
509
|
+
Uses Redis pipeline to batch TYPE / GET commands per SCAN page,
|
|
510
|
+
reducing round trips from O(N) to ~3 per page.
|
|
511
|
+
"""
|
|
512
|
+
pattern = make_object_key(self._domain.name, self._name, "*")
|
|
513
|
+
prefix = make_object_key_prefix(self._domain.name, self._name)
|
|
514
|
+
obj_prefix = make_object_key_prefix(self._domain.name, self._name)
|
|
515
|
+
r = self._redis()
|
|
516
|
+
cursor = 0
|
|
517
|
+
while True:
|
|
518
|
+
cursor, keys = r.scan(cursor=cursor, match=pattern, count=100)
|
|
519
|
+
named = [(k, k[len(prefix) :]) for k in keys if not k[len(prefix) :].startswith("~")]
|
|
520
|
+
if not named:
|
|
521
|
+
if cursor == 0:
|
|
522
|
+
break
|
|
523
|
+
continue
|
|
524
|
+
|
|
525
|
+
# Batch TYPE for all candidate keys in this page
|
|
526
|
+
pipe = r.pipeline(transaction=False)
|
|
527
|
+
for k, _ in named:
|
|
528
|
+
pipe.type(k)
|
|
529
|
+
key_types = pipe.execute()
|
|
530
|
+
|
|
531
|
+
# Owner bindings (hash): yield directly — SCAN confirmed existence
|
|
532
|
+
ref_keys = []
|
|
533
|
+
for (k, name), kt in zip(named, key_types):
|
|
534
|
+
if kt == "hash":
|
|
535
|
+
yield name
|
|
536
|
+
elif kt == "string":
|
|
537
|
+
ref_keys.append((k, name))
|
|
538
|
+
|
|
539
|
+
# Ref aliases (string): batch GET to find canonical key
|
|
540
|
+
if ref_keys:
|
|
541
|
+
pipe = r.pipeline(transaction=False)
|
|
542
|
+
for k, _ in ref_keys:
|
|
543
|
+
pipe.get(k)
|
|
544
|
+
ref_values = pipe.execute()
|
|
545
|
+
|
|
546
|
+
canonical_pairs = []
|
|
547
|
+
for (k, name), v in zip(ref_keys, ref_values):
|
|
548
|
+
if v and v.startswith("ref:"):
|
|
549
|
+
canonical = v[len("ref:") :]
|
|
550
|
+
if canonical.startswith(obj_prefix):
|
|
551
|
+
canonical_pairs.append((name, canonical))
|
|
552
|
+
|
|
553
|
+
# Batch TYPE for canonical keys
|
|
554
|
+
if canonical_pairs:
|
|
555
|
+
pipe = r.pipeline(transaction=False)
|
|
556
|
+
for _, ck in canonical_pairs:
|
|
557
|
+
pipe.type(ck)
|
|
558
|
+
canonical_types = pipe.execute()
|
|
559
|
+
|
|
560
|
+
for (name, _), ct in zip(canonical_pairs, canonical_types):
|
|
561
|
+
if ct == "hash":
|
|
562
|
+
yield name
|
|
563
|
+
|
|
564
|
+
if cursor == 0:
|
|
565
|
+
break
|
|
566
|
+
|
|
567
|
+
def __len__(self) -> int:
|
|
568
|
+
"""Return the number of named objects in this namespace.
|
|
569
|
+
|
|
570
|
+
Anonymous objects (names starting with ``~``) are not counted.
|
|
571
|
+
O(n) — iterates via SCAN.
|
|
572
|
+
"""
|
|
573
|
+
# by design: _ discards object names; sum counts via __iter__.
|
|
574
|
+
return sum(1 for _ in self)
|
|
575
|
+
|
|
576
|
+
def clear(self) -> None:
|
|
577
|
+
"""Delete all objects in this namespace, including anonymous objects.
|
|
578
|
+
|
|
579
|
+
Uses SCAN + DELETE for efficiency instead of the MutableMapping default
|
|
580
|
+
of repeated ``popitem()`` calls. The namespace ``__meta__`` key is
|
|
581
|
+
preserved so the namespace remains registered and its configuration
|
|
582
|
+
intact after the operation.
|
|
583
|
+
|
|
584
|
+
Because cross-namespace references are not supported (see class
|
|
585
|
+
docstring), it is safe to delete all keys in bulk without walking
|
|
586
|
+
the per-object referrer / cascade-delete logic used by
|
|
587
|
+
``__delitem__``.
|
|
588
|
+
"""
|
|
589
|
+
meta_key = make_namespace_meta_key(self._domain.name, self._name)
|
|
590
|
+
pattern = f"gpdc:{self._domain.name}:{self._name}:*"
|
|
591
|
+
r = self._redis()
|
|
592
|
+
cursor = 0
|
|
593
|
+
while True:
|
|
594
|
+
cursor, keys = r.scan(cursor=cursor, match=pattern, count=100)
|
|
595
|
+
to_delete = [k for k in keys if k != meta_key]
|
|
596
|
+
if to_delete:
|
|
597
|
+
r.delete(*to_delete)
|
|
598
|
+
if cursor == 0:
|
|
599
|
+
break
|
|
600
|
+
|
|
601
|
+
def destroy(self) -> None:
|
|
602
|
+
self._domain.delete_namespace(self._name)
|