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,110 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+
5
+ from ..types import GPDCTypeRegistry
6
+ from .bool_object import BoolCodec, BoolObject
7
+ from .datetime_object import (
8
+ DateCodec,
9
+ DateObject,
10
+ DateTimeCodec,
11
+ DateTimeObject,
12
+ TimeCodec,
13
+ TimeObject,
14
+ )
15
+ from .dict_object import DictCodec, DictObject
16
+ from .float_object import FloatCodec, FloatObject
17
+ from .int_object import IntCodec, IntObject
18
+ from .list_object import ListCodec, ListObject
19
+ from .none_object import NoneCodec, NoneObject
20
+ from .set_object import SetCodec, SetObject
21
+ from .str_object import StrCodec, StrObject
22
+ from .tuple_object import TupleCodec, TupleObject
23
+
24
+
25
+ def _register_builtin_types() -> None:
26
+ GPDCTypeRegistry.register(
27
+ python_type=type(None),
28
+ gpdc_type="scalar:none",
29
+ object_class=NoneObject,
30
+ codec_class=NoneCodec,
31
+ priority=0,
32
+ )
33
+ GPDCTypeRegistry.register(
34
+ python_type=bool,
35
+ gpdc_type="scalar:bool",
36
+ object_class=BoolObject,
37
+ codec_class=BoolCodec,
38
+ priority=10,
39
+ )
40
+ GPDCTypeRegistry.register(
41
+ python_type=int,
42
+ gpdc_type="scalar:int",
43
+ object_class=IntObject,
44
+ codec_class=IntCodec,
45
+ priority=0,
46
+ )
47
+ GPDCTypeRegistry.register(
48
+ python_type=float,
49
+ gpdc_type="scalar:float",
50
+ object_class=FloatObject,
51
+ codec_class=FloatCodec,
52
+ priority=0,
53
+ )
54
+ GPDCTypeRegistry.register(
55
+ python_type=str,
56
+ gpdc_type="scalar:str",
57
+ object_class=StrObject,
58
+ codec_class=StrCodec,
59
+ priority=0,
60
+ )
61
+ GPDCTypeRegistry.register(
62
+ python_type=datetime.datetime,
63
+ gpdc_type="scalar:datetime",
64
+ object_class=DateTimeObject,
65
+ codec_class=DateTimeCodec,
66
+ priority=10,
67
+ )
68
+ GPDCTypeRegistry.register(
69
+ python_type=datetime.date,
70
+ gpdc_type="scalar:date",
71
+ object_class=DateObject,
72
+ codec_class=DateCodec,
73
+ priority=0,
74
+ )
75
+ GPDCTypeRegistry.register(
76
+ python_type=datetime.time,
77
+ gpdc_type="scalar:time",
78
+ object_class=TimeObject,
79
+ codec_class=TimeCodec,
80
+ priority=0,
81
+ )
82
+ GPDCTypeRegistry.register(
83
+ python_type=tuple,
84
+ gpdc_type="scalar:tuple",
85
+ object_class=TupleObject,
86
+ codec_class=TupleCodec,
87
+ priority=0,
88
+ )
89
+
90
+ GPDCTypeRegistry.register(
91
+ python_type=list,
92
+ gpdc_type="collection:list",
93
+ object_class=ListObject,
94
+ codec_class=ListCodec,
95
+ priority=0,
96
+ )
97
+ GPDCTypeRegistry.register(
98
+ python_type=dict,
99
+ gpdc_type="collection:dict",
100
+ object_class=DictObject,
101
+ codec_class=DictCodec,
102
+ priority=0,
103
+ )
104
+ GPDCTypeRegistry.register(
105
+ python_type=set,
106
+ gpdc_type="collection:set",
107
+ object_class=SetObject,
108
+ codec_class=SetCodec,
109
+ priority=0,
110
+ )
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import collections.abc
4
+
5
+ from ..codec import GPDCContainerCodec
6
+ from ..container import GPDCContainerObject
7
+ from ..types import decode_key, encode_key
8
+
9
+
10
+ class SetCodec(GPDCContainerCodec):
11
+ """Codec for Python set values stored as Redis Sets."""
12
+
13
+ @classmethod
14
+ def create_raw(cls, namespace, raw_key: str, value: set, owner_name: str, cache_mode: str, ttl: int) -> None:
15
+ if not value:
16
+ return
17
+ from ..types import encode_key
18
+
19
+ encoded = [encode_key(elem) for elem in value]
20
+ namespace._redis().sadd(raw_key, *encoded)
21
+
22
+ @classmethod
23
+ def read_raw(cls, namespace, raw_key: str) -> set:
24
+ from ..types import decode_key
25
+
26
+ members = namespace._redis().smembers(raw_key)
27
+ if not members:
28
+ return set()
29
+ return {decode_key(m) for m in members}
30
+
31
+ @classmethod
32
+ def delete_raw(cls, namespace, raw_key: str) -> None:
33
+ namespace._redis().delete(raw_key)
34
+
35
+
36
+ class SetObject(GPDCContainerObject, collections.abc.MutableSet):
37
+ """Redis-backed set object implementing MutableSet."""
38
+
39
+ TYPE = "collection:set"
40
+
41
+ @property
42
+ def type(self) -> str:
43
+ return self.TYPE
44
+
45
+ @property
46
+ def value(self) -> set:
47
+ return SetCodec.read_raw(self._namespace, self.raw_key)
48
+
49
+ @value.setter
50
+ def value(self, new_value: set) -> None:
51
+ self._reject_value_replacement()
52
+
53
+ def __len__(self) -> int:
54
+ return self._redis().scard(self.raw_key)
55
+
56
+ def __contains__(self, item: object) -> bool:
57
+ encoded = encode_key(item)
58
+ return self._redis().sismember(self.raw_key, encoded)
59
+
60
+ def __iter__(self):
61
+ members = self._redis().smembers(self.raw_key)
62
+ for m in members:
63
+ yield decode_key(m)
64
+
65
+ def add(self, value: object) -> None:
66
+ encoded = encode_key(value)
67
+ self._redis().sadd(self.raw_key, encoded)
68
+ self._touch()
69
+
70
+ def discard(self, value: object) -> None:
71
+ encoded = encode_key(value)
72
+ removed = self._redis().srem(self.raw_key, encoded)
73
+ if removed:
74
+ self._touch()
75
+
76
+ def remove(self, value: object) -> None:
77
+ encoded = encode_key(value)
78
+ removed = self._redis().srem(self.raw_key, encoded)
79
+ if not removed:
80
+ raise KeyError(value)
81
+ self._touch()
82
+
83
+ def pop(self):
84
+ encoded = self._redis().spop(self.raw_key)
85
+ if encoded is None:
86
+ raise KeyError("pop from an empty set")
87
+ self._touch()
88
+ return decode_key(encoded)
89
+
90
+ def clear(self) -> None:
91
+ self._redis().delete(self.raw_key)
92
+ self._touch()
93
+
94
+ def _to_encoded_set(self, other) -> set[str]:
95
+ if isinstance(other, SetObject):
96
+ return self._redis().smembers(other.raw_key)
97
+ return {encode_key(e) for e in other}
98
+
99
+ def _binop(self, other, op):
100
+ self_set = self.value
101
+ other_set = other.value if isinstance(other, SetObject) else set(other)
102
+ return op(self_set, other_set)
103
+
104
+ def __or__(self, other):
105
+ """Return the union as a Python ``set`` (not a ``SetObject``).
106
+
107
+ Returning a ``SetObject`` would require creating a new Redis key,
108
+ which is an unexpected side-effect for a binary operator.
109
+ Use ``obj |= other`` for in-place mutation backed by Redis.
110
+ """
111
+ return self._binop(other, set.__or__)
112
+
113
+ def __and__(self, other):
114
+ """Return the intersection as a Python ``set``. See ``__or__``."""
115
+ return self._binop(other, set.__and__)
116
+
117
+ def __sub__(self, other):
118
+ """Return the difference as a Python ``set``. See ``__or__``."""
119
+ return self._binop(other, set.__sub__)
120
+
121
+ def __xor__(self, other):
122
+ """Return the symmetric difference as a Python ``set``. See ``__or__``."""
123
+ return self._binop(other, set.__xor__)
124
+
125
+ def __ior__(self, other):
126
+ other_encoded = self._to_encoded_set(other)
127
+ if other_encoded:
128
+ self._redis().sadd(self.raw_key, *other_encoded)
129
+ self._touch()
130
+ return self
131
+
132
+ def __iand__(self, other):
133
+ other_encoded = self._to_encoded_set(other)
134
+ current = self._redis().smembers(self.raw_key)
135
+ to_remove = current - other_encoded
136
+ if to_remove:
137
+ self._redis().srem(self.raw_key, *to_remove)
138
+ self._touch()
139
+ return self
140
+
141
+ def __isub__(self, other):
142
+ other_encoded = self._to_encoded_set(other)
143
+ if other_encoded:
144
+ self._redis().srem(self.raw_key, *other_encoded)
145
+ self._touch()
146
+ return self
147
+
148
+ def __ixor__(self, other):
149
+ other_encoded = self._to_encoded_set(other)
150
+ current = self._redis().smembers(self.raw_key)
151
+ to_add = other_encoded - current
152
+ to_remove = current & other_encoded
153
+ if to_remove:
154
+ self._redis().srem(self.raw_key, *to_remove)
155
+ if to_add:
156
+ self._redis().sadd(self.raw_key, *to_add)
157
+ self._touch()
158
+ return self
159
+
160
+ def deepcopy(self):
161
+ return self.value
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from ..codec import GPDCScalarCodec
4
+ from ..scalar import GPDCScalarObject
5
+
6
+
7
+ class StrCodec(GPDCScalarCodec):
8
+ @classmethod
9
+ def encode_value(cls, value: object) -> str:
10
+ if not isinstance(value, str):
11
+ raise TypeError(f"StrCodec cannot encode {type(value).__name__!r}")
12
+ return f"str:{value}"
13
+
14
+ @classmethod
15
+ def decode_value(cls, encoded: str) -> object:
16
+ type_name, _ = cls._parse_encoded(encoded)
17
+ if type_name != "str":
18
+ raise ValueError(f"StrCodec cannot decode type {type_name!r}")
19
+ return encoded[encoded.index(":") + 1 :]
20
+
21
+
22
+ class StrObject(GPDCScalarObject):
23
+ TYPE = "scalar:str"
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from ..codec import GPDCScalarCodec
6
+ from ..scalar import GPDCScalarObject
7
+
8
+
9
+ class TupleCodec(GPDCScalarCodec):
10
+ @classmethod
11
+ def encode_value(cls, value: object) -> str:
12
+ if not isinstance(value, tuple):
13
+ raise TypeError(f"TupleCodec cannot encode {type(value).__name__!r}")
14
+ from ..types import GPDCTypeRegistry
15
+
16
+ encoded_elems = []
17
+ for elem in value:
18
+ if isinstance(elem, (list, dict, set)):
19
+ raise TypeError(f"Cannot encode non-scalar tuple element: {type(elem).__name__!r}")
20
+ entry = GPDCTypeRegistry.lookup_by_python_type(elem)
21
+ encoded_elems.append(entry.codec_class.encode_value(elem))
22
+ return "tuple:" + json.dumps(encoded_elems, separators=(",", ":"))
23
+
24
+ @classmethod
25
+ def decode_value(cls, encoded: str) -> object:
26
+ prefix = "tuple:"
27
+ if not encoded.startswith(prefix):
28
+ raise ValueError(f"Not a tuple-encoded string: {encoded!r}")
29
+ json_part = encoded[len(prefix) :]
30
+ encoded_elems = json.loads(json_part)
31
+ from ..types import GPDCTypeRegistry
32
+
33
+ result = []
34
+ for elem_encoded in encoded_elems:
35
+ if not elem_encoded or ":" not in elem_encoded:
36
+ raise ValueError(f"Invalid tuple element encoding: {elem_encoded!r}")
37
+ colon_idx = elem_encoded.index(":")
38
+ type_name = elem_encoded[:colon_idx]
39
+ gpdc_type = f"scalar:{type_name}"
40
+ entry = GPDCTypeRegistry.lookup_by_gpdc_type(gpdc_type)
41
+ result.append(entry.codec_class.decode_value(elem_encoded))
42
+ return tuple(result)
43
+
44
+
45
+ # by design: tuple 注册为 scalar 而非 container。tuple 是不可变的,语义上作为"复合标量"
46
+ # (类似数据库复合主键)存储为单个 Redis string,不占用独立的 raw key。TupleCodec 显式
47
+ # 拒绝 list/dict/set 元素,避免嵌套引用带来的 GC 复杂性。
48
+ class TupleObject(GPDCScalarObject):
49
+ TYPE = "scalar:tuple"
gpdatacached/codec.py ADDED
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class GPDCCodec(ABC):
7
+ """Abstract base class for all GPDC codecs."""
8
+
9
+ @classmethod
10
+ @abstractmethod
11
+ def encode_value(cls, value: object) -> str:
12
+ """Encode a Python value to a string for storage in Redis metadata."""
13
+
14
+ @classmethod
15
+ @abstractmethod
16
+ def decode_value(cls, encoded: str) -> object:
17
+ """Decode a stored string back to a Python value."""
18
+
19
+
20
+ class GPDCScalarCodec(GPDCCodec):
21
+ """Abstract base class for all scalar codecs."""
22
+
23
+ @classmethod
24
+ def _parse_encoded(cls, encoded: str) -> tuple[str, str]:
25
+ if not encoded or ":" not in encoded:
26
+ raise ValueError(f"Invalid scalar encoding: {encoded!r}")
27
+ colon_idx = encoded.index(":")
28
+ return encoded[:colon_idx], encoded[colon_idx + 1 :]
29
+
30
+
31
+ class GPDCContainerCodec(ABC):
32
+ """Abstract base class for container codecs (list, dict, etc.)."""
33
+
34
+ @classmethod
35
+ @abstractmethod
36
+ def create_raw(cls, namespace, raw_key: str, value: object, owner_name: str, cache_mode: str, ttl: int) -> None:
37
+ """Create raw data storage in Redis for the given value."""
38
+
39
+ @classmethod
40
+ @abstractmethod
41
+ def read_raw(cls, namespace, raw_key: str) -> object:
42
+ """Restore a Python object from raw data in Redis."""
43
+
44
+ @classmethod
45
+ @abstractmethod
46
+ def delete_raw(cls, namespace, raw_key: str) -> None:
47
+ """Delete raw data storage from Redis."""
gpdatacached/config.py ADDED
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import ClassVar, Optional
4
+
5
+ from gpconfig import GPConfig
6
+ from pydantic import Field, field_validator, model_validator
7
+
8
+ from .lifecycle import VALID_CACHE_MODES, validate_cache_mode_and_ttl
9
+
10
+
11
+ class GPDCNamespaceConfig(GPConfig):
12
+ """Configuration for a GPDC namespace."""
13
+
14
+ cfg_class_name: ClassVar[str] = "GPDCNamespaceConfig"
15
+
16
+ default_cache_mode: Optional[str] = None
17
+ default_ttl_seconds: Optional[int] = None
18
+
19
+ @field_validator("default_cache_mode")
20
+ @classmethod
21
+ def _validate_cache_mode(cls, v: str | None) -> str | None:
22
+ if v is not None and v not in VALID_CACHE_MODES:
23
+ raise ValueError(f"Invalid default_cache_mode: {v!r}")
24
+ return v
25
+
26
+ @field_validator("default_ttl_seconds")
27
+ @classmethod
28
+ def _validate_ttl(cls, v: int | None) -> int | None:
29
+ if v is not None and v < 0:
30
+ raise ValueError(f"Invalid default_ttl_seconds: {v!r}")
31
+ return v
32
+
33
+ @model_validator(mode="after")
34
+ def _validate_ttl_mode_consistency(self) -> GPDCNamespaceConfig:
35
+ if self.default_cache_mode is not None and self.default_ttl_seconds is not None:
36
+ validate_cache_mode_and_ttl(self.default_cache_mode, self.default_ttl_seconds)
37
+ return self
38
+
39
+
40
+ class GPDCDomainConfig(GPConfig):
41
+ """Configuration for a GPDC domain."""
42
+
43
+ cfg_class_name: ClassVar[str] = "GPDCDomainConfig"
44
+
45
+ redis_host: str = "localhost"
46
+ redis_port: int = 6379
47
+ redis_db: int = 0
48
+ redis_password: str = ""
49
+
50
+ default_cache_mode: str = "permanent"
51
+ default_ttl_seconds: int = 0
52
+
53
+ gc_enabled: bool = False
54
+ gc_interval_seconds: int = 300
55
+
56
+ namespaces: dict[str, GPDCNamespaceConfig] = Field(default_factory=dict)
57
+
58
+ # Lock configuration (see dev_docs/2026-06-21-cross-process-locking-design.md).
59
+ default_lock_ttl_seconds: int = 30
60
+ default_lock_acquire_timeout_seconds: int = 30
61
+ gc_lock_ttl_seconds: int = 30
62
+ gc_lock_wait_seconds: int = 5
63
+
64
+ @field_validator("default_cache_mode")
65
+ @classmethod
66
+ def _validate_cache_mode(cls, v: str) -> str:
67
+ if v not in VALID_CACHE_MODES:
68
+ raise ValueError(f"Invalid default_cache_mode: {v!r}")
69
+ return v
70
+
71
+ @field_validator("default_ttl_seconds")
72
+ @classmethod
73
+ def _validate_ttl(cls, v: int) -> int:
74
+ if v < 0:
75
+ raise ValueError(f"Invalid default_ttl_seconds: {v!r}")
76
+ return v
77
+
78
+ @field_validator(
79
+ "default_lock_ttl_seconds",
80
+ "gc_lock_ttl_seconds",
81
+ )
82
+ @classmethod
83
+ def _validate_lock_ttl_fields(cls, v: int) -> int:
84
+ if v < 1:
85
+ raise ValueError(f"Lock TTL must be >= 1 (Redis SET NX EX requires positive ttl), got {v!r}")
86
+ return v
87
+
88
+ @field_validator(
89
+ "default_lock_acquire_timeout_seconds",
90
+ "gc_lock_wait_seconds",
91
+ )
92
+ @classmethod
93
+ def _validate_lock_wait_fields(cls, v: int) -> int:
94
+ if v < 0:
95
+ raise ValueError(f"Lock wait/timeout must be >= 0 (0 = non-blocking), got {v!r}")
96
+ return v
97
+
98
+ @model_validator(mode="after")
99
+ def _validate_ttl_mode_consistency(self) -> GPDCDomainConfig:
100
+ validate_cache_mode_and_ttl(self.default_cache_mode, self.default_ttl_seconds)
101
+ return self
@@ -0,0 +1,205 @@
1
+ # src/gpdatacached/container.py
2
+ """GPDC container object hierarchy."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import datetime
7
+ from abc import ABC, abstractmethod
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from .namespace import GPDCNamespace
12
+
13
+ from .keys import make_object_key, make_raw_key, make_ref_key, parse_ref_encoded
14
+ from .object import GPDCDataObject
15
+ from .meta import ObjectMeta
16
+ from .refset import release_ref, get_referrers, has_referrers
17
+ from .types import decode_element
18
+
19
+
20
+ class GPDCContainerObject(GPDCDataObject, ABC):
21
+ """Abstract base for container GPDC objects (lists, dicts, sets).
22
+
23
+ A container object stores its elements in a dedicated Redis key
24
+ (``raw_key``) and tracks which other objects reference its raw data via
25
+ a Redis hash (``ref_key``). Subclasses implement ``MutableSequence``,
26
+ ``MutableMapping``, or ``MutableSet`` semantics on top of this shared
27
+ infrastructure.
28
+
29
+ Key attributes:
30
+ raw_key: Redis key holding the container's element data (e.g. a list
31
+ or hash).
32
+ ref_key: Redis hash key recording referrer names and their reference
33
+ counts; used for ref-counting and cascade-deletion of anonymous
34
+ objects.
35
+ """
36
+
37
+ def __init__(self, name: str, namespace: "GPDCNamespace", *, canonical_name: str | None = None) -> None:
38
+ super().__init__(name, namespace, canonical_name=canonical_name)
39
+ domain_name = namespace.domain.name
40
+ ns_name = namespace.name
41
+ self.raw_key: str = make_raw_key(domain_name, ns_name, name)
42
+ self.ref_key: str = make_ref_key(domain_name, ns_name, name)
43
+
44
+ # ── Lifecycle: timestamps & sliding TTL ──────────────────────────
45
+
46
+ def _touch(self) -> None:
47
+ """Update metadata timestamps and apply sliding TTL if configured.
48
+
49
+ When owned by a parent, the sliding refresh propagates upward
50
+ through the owner chain.
51
+ """
52
+ from .lifecycle import policy_from_meta
53
+ from .meta import ObjectMeta
54
+ from .exceptions import ObjectGoneError
55
+
56
+ key = self._canonical_redis_key()
57
+ now = datetime.datetime.now(datetime.UTC)
58
+ r = self._redis()
59
+ item_count = self._current_item_count()
60
+ meta = ObjectMeta.load(r, key)
61
+ if meta is None:
62
+ raise ObjectGoneError(self.canonical_uri)
63
+ meta.touch(now, item_count=item_count).save(r, key)
64
+
65
+ if meta.owner == self.canonical_name:
66
+ policy = policy_from_meta(meta)
67
+ if policy.cache_mode == "sliding" and policy.declared_ttl > 0:
68
+ self.refresh_ttl()
69
+ else:
70
+ self._propagate_sliding_ttl_upward(meta.owner, r)
71
+
72
+ def _current_item_count(self) -> int:
73
+ """Return the current number of elements in this container."""
74
+ return len(self)
75
+
76
+ def _propagate_sliding_ttl_upward(self, owner_name: str, r) -> None:
77
+ """Propagate a sliding-window TTL refresh up the owner chain to the first self-owned ancestor."""
78
+ from .lifecycle import policy_from_meta
79
+ from .keys import make_object_key
80
+
81
+ while owner_name:
82
+ owner_obj_key = make_object_key(self.domain.name, self.namespace.name, owner_name)
83
+ owner_meta = ObjectMeta.load(r, owner_obj_key)
84
+ if owner_meta is None:
85
+ break
86
+ if owner_meta.owner == owner_name:
87
+ policy = policy_from_meta(owner_meta)
88
+ if policy.cache_mode == "sliding" and policy.declared_ttl > 0:
89
+ owner_obj = self.namespace.get_object(owner_name)
90
+ owner_obj.refresh_ttl()
91
+ break
92
+ owner_name = owner_meta.owner
93
+
94
+ # ── Identity: data owner resolution & encoding ───────────────────
95
+
96
+ def _data_owner_name(self) -> str:
97
+ """Return the canonical name that owns this container's raw data.
98
+
99
+ For references, returns ``canonical_name``; for owners, loads meta
100
+ to verify the raw_key mapping.
101
+ """
102
+ if self._is_reference:
103
+ return self._canonical_name
104
+ meta = ObjectMeta.load(self._redis(), self._redis_key())
105
+ if meta is None:
106
+ return self._name
107
+ raw_key = meta.value or self.raw_key
108
+ prefix = f"gpdc:{self._namespace.domain.name}:{self._namespace.name}:raw:"
109
+ if raw_key.startswith(prefix):
110
+ self.raw_key = raw_key
111
+ return raw_key[len(prefix) :]
112
+ return self._name
113
+
114
+ def _encode_as_ref_value(self) -> str:
115
+ """Encode this container as a ``ref:`` string for storage in parent raw data."""
116
+ owner_name = self._data_owner_name()
117
+ obj_key = make_object_key(self._namespace.domain.name, self._namespace.name, owner_name)
118
+ return f"ref:{obj_key}"
119
+
120
+ # ── Element reference tracking ────────────────────────────────────
121
+
122
+ def _release_element_ref(self, old_encoded: str | None) -> None:
123
+ """Release the ref binding held by a removed or overwritten element.
124
+
125
+ Decrements the reference count in the target's ref hash. If the
126
+ count reaches zero, cascade-deletes the target if it is an
127
+ orphaned anonymous object.
128
+ """
129
+ if not isinstance(old_encoded, str) or not old_encoded.startswith("ref:"):
130
+ return
131
+ target_name = parse_ref_encoded(old_encoded)
132
+ if target_name is None:
133
+ return
134
+ target_ref_key = make_ref_key(self._namespace.domain.name, self._namespace.name, target_name)
135
+ fully_released = release_ref(self._redis(), target_ref_key, self._data_owner_name())
136
+ if fully_released:
137
+ self._gc_anonymous_if_orphaned(target_name)
138
+
139
+ def _gc_anonymous_if_orphaned(self, target_name: str) -> None:
140
+ """Cascade-delete an anonymous object if it has no remaining referrers."""
141
+ if not target_name.startswith("~"):
142
+ return
143
+ target_ref_key = make_ref_key(self._namespace.domain.name, self._namespace.name, target_name)
144
+ r = self._redis()
145
+ if has_referrers(r, target_ref_key):
146
+ return
147
+ target_obj_key = make_object_key(self._namespace.domain.name, self._namespace.name, target_name)
148
+ target_meta = ObjectMeta.load(r, target_obj_key)
149
+ if target_meta is None or target_meta.owner == self._data_owner_name():
150
+ self._namespace._cascade_delete_anonymous(target_name)
151
+
152
+ # ── Value decode helpers ──────────────────────────────────────────
153
+
154
+ def _is_ref_encoded(self, encoded: str | None) -> bool:
155
+ """Check whether ``encoded`` value is a ``ref:`` pointer string."""
156
+ return isinstance(encoded, str) and encoded.startswith("ref:")
157
+
158
+ def _decode_and_copy_value(self, encoded: str) -> object:
159
+ """Decode an encoded element; deep-copy if it resolves to a container."""
160
+ value = decode_element(encoded, self._namespace)
161
+ if self._is_ref_encoded(encoded) and hasattr(value, "deepcopy"):
162
+ return value.deepcopy()
163
+ return value
164
+
165
+ def _reject_value_replacement(self) -> None:
166
+ """Always raise — containers require explicit mutation methods."""
167
+ raise TypeError("Container value replacement is not supported; use explicit mutating methods instead")
168
+
169
+ # ── Public inspection API ─────────────────────────────────────────
170
+
171
+ def referrers(self) -> set[str]:
172
+ """Return the set of object names referencing this container's raw data."""
173
+ owner_name = self._data_owner_name()
174
+ ref_key = make_ref_key(self._namespace.domain.name, self._namespace.name, owner_name)
175
+ return get_referrers(self._redis(), ref_key)
176
+
177
+ def has_referrers(self) -> bool:
178
+ """Return whether any other object references this container's raw data.
179
+
180
+ This is a cheap boolean check (no full set materialisation) to let callers
181
+ decide whether a ``del``/``expire_at`` of the owner would be blocked by
182
+ outstanding references. Resolving references before deletion/expiry is the
183
+ caller's responsibility — the library raises
184
+ :class:`~gpdatacached.exceptions.CanonicalObjectReferencedError` to *signal*
185
+ a blocked teardown, not to handle it.
186
+ """
187
+ owner_name = self._data_owner_name()
188
+ ref_key = make_ref_key(self._namespace.domain.name, self._namespace.name, owner_name)
189
+ return has_referrers(self._redis(), ref_key)
190
+
191
+ def copy(self) -> object:
192
+ """Shallow copy: return Python value with shared anon object refs."""
193
+ return self.value
194
+
195
+ def deepcopy(self) -> object:
196
+ """Deep copy: recursively clone all anon objects, return Python value."""
197
+ raise NotImplementedError("Subclass must implement deepcopy()")
198
+
199
+ @property
200
+ @abstractmethod
201
+ def value(self) -> object: ...
202
+
203
+ @value.setter
204
+ @abstractmethod
205
+ def value(self, new_value: object) -> None: ...