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.
@@ -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)