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,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: ...
|