dory-sdk 2.1.0__py3-none-any.whl → 2.1.4__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.
- dory/__init__.py +32 -1
- dory/config/defaults.py +6 -0
- dory/config/schema.py +26 -0
- dory/edge/__init__.py +88 -0
- dory/edge/adaptive.py +648 -0
- dory/edge/detector.py +546 -0
- dory/edge/fencing.py +488 -0
- dory/edge/heartbeat.py +598 -0
- dory/edge/role.py +416 -0
- dory/health/server.py +283 -9
- dory/k8s/__init__.py +69 -0
- dory/k8s/labels.py +505 -0
- dory/migration/__init__.py +49 -0
- dory/migration/s3_store.py +656 -0
- dory/migration/state_manager.py +64 -6
- dory/migration/transfer.py +382 -0
- dory/migration/versioning.py +749 -0
- {dory_sdk-2.1.0.dist-info → dory_sdk-2.1.4.dist-info}/METADATA +37 -32
- {dory_sdk-2.1.0.dist-info → dory_sdk-2.1.4.dist-info}/RECORD +22 -15
- dory_sdk-2.1.4.dist-info/entry_points.txt +2 -0
- dory/sidecar/__init__.py +0 -6
- dory/sidecar/main.py +0 -75
- dory/sidecar/server.py +0 -329
- dory_sdk-2.1.0.dist-info/entry_points.txt +0 -3
- {dory_sdk-2.1.0.dist-info → dory_sdk-2.1.4.dist-info}/WHEEL +0 -0
- {dory_sdk-2.1.0.dist-info → dory_sdk-2.1.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
"""State format version negotiation.
|
|
2
|
+
|
|
3
|
+
Provides version-aware serialization and deserialization to ensure
|
|
4
|
+
compatibility between different versions of the SDK and Orchestrator.
|
|
5
|
+
|
|
6
|
+
Format Versions:
|
|
7
|
+
v1.0 - Original SDK format (StateEnvelope with payload/metadata/checksum)
|
|
8
|
+
v2.0 - Orchestrator-compatible format (ApplicationState structure)
|
|
9
|
+
|
|
10
|
+
The SDK supports reading both formats and can write in either format
|
|
11
|
+
depending on the target version.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from enum import Enum
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
|
|
22
|
+
from dory.utils.errors import DoryStateError
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# =============================================================================
|
|
28
|
+
# Version Constants
|
|
29
|
+
# =============================================================================
|
|
30
|
+
|
|
31
|
+
class StateFormatVersion(Enum):
|
|
32
|
+
"""State format versions."""
|
|
33
|
+
|
|
34
|
+
V1_0 = "1.0" # Original SDK format (StateEnvelope)
|
|
35
|
+
V2_0 = "2.0" # Orchestrator-compatible format (ApplicationState)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def latest(cls) -> "StateFormatVersion":
|
|
39
|
+
"""Get the latest format version."""
|
|
40
|
+
return cls.V2_0
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_string(cls, version: str) -> "StateFormatVersion":
|
|
44
|
+
"""Parse version string.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
version: Version string like "1.0" or "2.0"
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
StateFormatVersion enum
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
ValueError: If version is not recognized
|
|
54
|
+
"""
|
|
55
|
+
# Handle various formats
|
|
56
|
+
version = version.strip().lstrip("v")
|
|
57
|
+
|
|
58
|
+
for v in cls:
|
|
59
|
+
if v.value == version:
|
|
60
|
+
return v
|
|
61
|
+
|
|
62
|
+
raise ValueError(f"Unknown state format version: {version}")
|
|
63
|
+
|
|
64
|
+
def is_compatible_with(self, other: "StateFormatVersion") -> bool:
|
|
65
|
+
"""Check if this version can read data from another version."""
|
|
66
|
+
# V2.0 can read V1.0 (backward compatible)
|
|
67
|
+
# V1.0 cannot read V2.0 (forward incompatible)
|
|
68
|
+
if self == StateFormatVersion.V2_0:
|
|
69
|
+
return True # V2 can read all versions
|
|
70
|
+
elif self == StateFormatVersion.V1_0:
|
|
71
|
+
return other == StateFormatVersion.V1_0
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Current SDK version
|
|
76
|
+
SDK_STATE_VERSION = StateFormatVersion.V2_0
|
|
77
|
+
|
|
78
|
+
# Minimum supported version for reading
|
|
79
|
+
MIN_SUPPORTED_VERSION = StateFormatVersion.V1_0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# =============================================================================
|
|
83
|
+
# State Structures
|
|
84
|
+
# =============================================================================
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class StateMetadata:
|
|
88
|
+
"""Metadata accompanying state data."""
|
|
89
|
+
|
|
90
|
+
# Timing
|
|
91
|
+
timestamp: float = field(default_factory=time.time)
|
|
92
|
+
timestamp_iso: str = ""
|
|
93
|
+
|
|
94
|
+
# Identity
|
|
95
|
+
processor_id: str = ""
|
|
96
|
+
pod_name: str = ""
|
|
97
|
+
app_name: str = ""
|
|
98
|
+
|
|
99
|
+
# Runtime info
|
|
100
|
+
restart_count: int = 0
|
|
101
|
+
uptime_seconds: float = 0.0
|
|
102
|
+
request_count: int = 0
|
|
103
|
+
|
|
104
|
+
# Version info
|
|
105
|
+
format_version: str = SDK_STATE_VERSION.value
|
|
106
|
+
sdk_version: str = ""
|
|
107
|
+
|
|
108
|
+
def __post_init__(self):
|
|
109
|
+
"""Set derived fields."""
|
|
110
|
+
if not self.timestamp_iso:
|
|
111
|
+
self.timestamp_iso = time.strftime(
|
|
112
|
+
"%Y-%m-%dT%H:%M:%SZ",
|
|
113
|
+
time.gmtime(self.timestamp)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def to_dict(self) -> dict[str, Any]:
|
|
117
|
+
"""Convert to dictionary."""
|
|
118
|
+
return {
|
|
119
|
+
"timestamp": self.timestamp,
|
|
120
|
+
"timestamp_iso": self.timestamp_iso,
|
|
121
|
+
"processor_id": self.processor_id,
|
|
122
|
+
"pod_name": self.pod_name,
|
|
123
|
+
"app_name": self.app_name,
|
|
124
|
+
"restart_count": self.restart_count,
|
|
125
|
+
"uptime_seconds": self.uptime_seconds,
|
|
126
|
+
"request_count": self.request_count,
|
|
127
|
+
"format_version": self.format_version,
|
|
128
|
+
"sdk_version": self.sdk_version,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_dict(cls, data: dict[str, Any]) -> "StateMetadata":
|
|
133
|
+
"""Create from dictionary."""
|
|
134
|
+
return cls(
|
|
135
|
+
timestamp=data.get("timestamp", time.time()),
|
|
136
|
+
timestamp_iso=data.get("timestamp_iso", ""),
|
|
137
|
+
processor_id=data.get("processor_id", ""),
|
|
138
|
+
pod_name=data.get("pod_name", ""),
|
|
139
|
+
app_name=data.get("app_name", ""),
|
|
140
|
+
restart_count=data.get("restart_count", 0),
|
|
141
|
+
uptime_seconds=data.get("uptime_seconds", 0.0),
|
|
142
|
+
request_count=data.get("request_count", 0),
|
|
143
|
+
format_version=data.get("format_version", SDK_STATE_VERSION.value),
|
|
144
|
+
sdk_version=data.get("sdk_version", ""),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class VersionedState:
|
|
150
|
+
"""Version-aware state container.
|
|
151
|
+
|
|
152
|
+
This is the canonical internal representation that can be
|
|
153
|
+
serialized to any supported format version.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
# Core data
|
|
157
|
+
data: dict[str, Any]
|
|
158
|
+
metadata: StateMetadata
|
|
159
|
+
|
|
160
|
+
# Integrity
|
|
161
|
+
checksum: str = ""
|
|
162
|
+
|
|
163
|
+
# Optional fields (for Orchestrator compatibility)
|
|
164
|
+
metrics: dict[str, float] = field(default_factory=dict)
|
|
165
|
+
connections: list[str] = field(default_factory=list)
|
|
166
|
+
active_sessions: int = 0
|
|
167
|
+
session_data: dict[str, str] = field(default_factory=dict)
|
|
168
|
+
|
|
169
|
+
def __post_init__(self):
|
|
170
|
+
"""Compute checksum if not provided."""
|
|
171
|
+
if not self.checksum:
|
|
172
|
+
self.checksum = self._compute_checksum()
|
|
173
|
+
|
|
174
|
+
def _compute_checksum(self) -> str:
|
|
175
|
+
"""Compute SHA256 checksum of data."""
|
|
176
|
+
data_json = json.dumps(self.data, sort_keys=True)
|
|
177
|
+
return hashlib.sha256(data_json.encode()).hexdigest()
|
|
178
|
+
|
|
179
|
+
def validate_checksum(self) -> bool:
|
|
180
|
+
"""Validate that checksum matches data."""
|
|
181
|
+
expected = self._compute_checksum()
|
|
182
|
+
return self.checksum == expected
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def format_version(self) -> StateFormatVersion:
|
|
186
|
+
"""Get format version."""
|
|
187
|
+
return StateFormatVersion.from_string(self.metadata.format_version)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# =============================================================================
|
|
191
|
+
# Version Detection
|
|
192
|
+
# =============================================================================
|
|
193
|
+
|
|
194
|
+
def detect_format_version(data: dict[str, Any]) -> StateFormatVersion:
|
|
195
|
+
"""Detect the format version of serialized state.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
data: Parsed JSON state data
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Detected StateFormatVersion
|
|
202
|
+
"""
|
|
203
|
+
# Check for explicit version field
|
|
204
|
+
if "state_version" in data:
|
|
205
|
+
# Orchestrator V2 format
|
|
206
|
+
try:
|
|
207
|
+
return StateFormatVersion.from_string(data["state_version"])
|
|
208
|
+
except ValueError:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
if "format_version" in data.get("metadata", {}):
|
|
212
|
+
# SDK format with version in metadata
|
|
213
|
+
try:
|
|
214
|
+
return StateFormatVersion.from_string(data["metadata"]["format_version"])
|
|
215
|
+
except ValueError:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
# Check structure to detect format
|
|
219
|
+
if "payload" in data and "metadata" in data and "checksum" in data:
|
|
220
|
+
# V1.0 SDK format (StateEnvelope)
|
|
221
|
+
return StateFormatVersion.V1_0
|
|
222
|
+
|
|
223
|
+
if "data" in data and ("pod_name" in data or "app_name" in data):
|
|
224
|
+
# V2.0 Orchestrator format (ApplicationState)
|
|
225
|
+
return StateFormatVersion.V2_0
|
|
226
|
+
|
|
227
|
+
# Default to V1.0 for backward compatibility
|
|
228
|
+
logger.warning("Could not detect state format version, assuming V1.0")
|
|
229
|
+
return StateFormatVersion.V1_0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# =============================================================================
|
|
233
|
+
# Serializers
|
|
234
|
+
# =============================================================================
|
|
235
|
+
|
|
236
|
+
class StateSerializerV1:
|
|
237
|
+
"""Serializer for V1.0 format (original SDK StateEnvelope)."""
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def serialize(state: VersionedState) -> str:
|
|
241
|
+
"""Serialize to V1.0 format.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
state: VersionedState to serialize
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
JSON string in V1.0 format
|
|
248
|
+
"""
|
|
249
|
+
envelope = {
|
|
250
|
+
"payload": state.data,
|
|
251
|
+
"metadata": {
|
|
252
|
+
"timestamp": state.metadata.timestamp,
|
|
253
|
+
"timestamp_iso": state.metadata.timestamp_iso,
|
|
254
|
+
"processor_id": state.metadata.processor_id,
|
|
255
|
+
"pod_name": state.metadata.pod_name,
|
|
256
|
+
"restart_count": state.metadata.restart_count,
|
|
257
|
+
},
|
|
258
|
+
"checksum": state.checksum,
|
|
259
|
+
}
|
|
260
|
+
return json.dumps(envelope, indent=2)
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def deserialize(data: str) -> VersionedState:
|
|
264
|
+
"""Deserialize from V1.0 format.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
data: JSON string in V1.0 format
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
VersionedState
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
DoryStateError: If deserialization fails
|
|
274
|
+
"""
|
|
275
|
+
try:
|
|
276
|
+
envelope = json.loads(data)
|
|
277
|
+
except json.JSONDecodeError as e:
|
|
278
|
+
raise DoryStateError(f"Invalid JSON: {e}", cause=e)
|
|
279
|
+
|
|
280
|
+
if "payload" not in envelope:
|
|
281
|
+
raise DoryStateError("Missing 'payload' field in V1.0 state")
|
|
282
|
+
|
|
283
|
+
metadata_dict = envelope.get("metadata", {})
|
|
284
|
+
metadata = StateMetadata(
|
|
285
|
+
timestamp=metadata_dict.get("timestamp", time.time()),
|
|
286
|
+
timestamp_iso=metadata_dict.get("timestamp_iso", ""),
|
|
287
|
+
processor_id=metadata_dict.get("processor_id", ""),
|
|
288
|
+
pod_name=metadata_dict.get("pod_name", ""),
|
|
289
|
+
restart_count=metadata_dict.get("restart_count", 0),
|
|
290
|
+
format_version=StateFormatVersion.V1_0.value,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return VersionedState(
|
|
294
|
+
data=envelope["payload"],
|
|
295
|
+
metadata=metadata,
|
|
296
|
+
checksum=envelope.get("checksum", ""),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class StateSerializerV2:
|
|
301
|
+
"""Serializer for V2.0 format (Orchestrator ApplicationState)."""
|
|
302
|
+
|
|
303
|
+
@staticmethod
|
|
304
|
+
def serialize(state: VersionedState) -> str:
|
|
305
|
+
"""Serialize to V2.0 format (Orchestrator-compatible).
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
state: VersionedState to serialize
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
JSON string in V2.0 format
|
|
312
|
+
"""
|
|
313
|
+
app_state = {
|
|
314
|
+
# Metadata
|
|
315
|
+
"pod_name": state.metadata.pod_name,
|
|
316
|
+
"app_name": state.metadata.app_name or state.metadata.processor_id,
|
|
317
|
+
"captured_at": state.metadata.timestamp_iso,
|
|
318
|
+
"state_version": StateFormatVersion.V2_0.value,
|
|
319
|
+
|
|
320
|
+
# Application data
|
|
321
|
+
"data": state.data,
|
|
322
|
+
"metrics": state.metrics,
|
|
323
|
+
"connections": state.connections,
|
|
324
|
+
|
|
325
|
+
# Session info
|
|
326
|
+
"active_sessions": state.active_sessions,
|
|
327
|
+
"session_data": state.session_data,
|
|
328
|
+
|
|
329
|
+
# Runtime
|
|
330
|
+
"uptime_seconds": state.metadata.uptime_seconds,
|
|
331
|
+
"request_count": state.metadata.request_count,
|
|
332
|
+
"last_health_time": state.metadata.timestamp_iso,
|
|
333
|
+
|
|
334
|
+
# SDK-specific (for round-trip compatibility)
|
|
335
|
+
"_sdk_metadata": {
|
|
336
|
+
"processor_id": state.metadata.processor_id,
|
|
337
|
+
"restart_count": state.metadata.restart_count,
|
|
338
|
+
"checksum": state.checksum,
|
|
339
|
+
"format_version": StateFormatVersion.V2_0.value,
|
|
340
|
+
"sdk_version": state.metadata.sdk_version,
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
return json.dumps(app_state, indent=2)
|
|
344
|
+
|
|
345
|
+
@staticmethod
|
|
346
|
+
def deserialize(data: str) -> VersionedState:
|
|
347
|
+
"""Deserialize from V2.0 format.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
data: JSON string in V2.0 format
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
VersionedState
|
|
354
|
+
|
|
355
|
+
Raises:
|
|
356
|
+
DoryStateError: If deserialization fails
|
|
357
|
+
"""
|
|
358
|
+
try:
|
|
359
|
+
app_state = json.loads(data)
|
|
360
|
+
except json.JSONDecodeError as e:
|
|
361
|
+
raise DoryStateError(f"Invalid JSON: {e}", cause=e)
|
|
362
|
+
|
|
363
|
+
# Extract SDK metadata if present
|
|
364
|
+
sdk_metadata = app_state.get("_sdk_metadata", {})
|
|
365
|
+
|
|
366
|
+
# Parse captured_at timestamp
|
|
367
|
+
timestamp = time.time()
|
|
368
|
+
timestamp_iso = app_state.get("captured_at", "")
|
|
369
|
+
if timestamp_iso:
|
|
370
|
+
try:
|
|
371
|
+
import datetime
|
|
372
|
+
dt = datetime.datetime.fromisoformat(timestamp_iso.replace("Z", "+00:00"))
|
|
373
|
+
timestamp = dt.timestamp()
|
|
374
|
+
except (ValueError, AttributeError):
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
metadata = StateMetadata(
|
|
378
|
+
timestamp=timestamp,
|
|
379
|
+
timestamp_iso=timestamp_iso,
|
|
380
|
+
processor_id=sdk_metadata.get("processor_id", app_state.get("app_name", "")),
|
|
381
|
+
pod_name=app_state.get("pod_name", ""),
|
|
382
|
+
app_name=app_state.get("app_name", ""),
|
|
383
|
+
restart_count=sdk_metadata.get("restart_count", 0),
|
|
384
|
+
uptime_seconds=app_state.get("uptime_seconds", 0.0),
|
|
385
|
+
request_count=app_state.get("request_count", 0),
|
|
386
|
+
format_version=StateFormatVersion.V2_0.value,
|
|
387
|
+
sdk_version=sdk_metadata.get("sdk_version", ""),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
return VersionedState(
|
|
391
|
+
data=app_state.get("data", {}),
|
|
392
|
+
metadata=metadata,
|
|
393
|
+
checksum=sdk_metadata.get("checksum", ""),
|
|
394
|
+
metrics=app_state.get("metrics", {}),
|
|
395
|
+
connections=app_state.get("connections", []),
|
|
396
|
+
active_sessions=app_state.get("active_sessions", 0),
|
|
397
|
+
session_data=app_state.get("session_data", {}),
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# =============================================================================
|
|
402
|
+
# Version Negotiation
|
|
403
|
+
# =============================================================================
|
|
404
|
+
|
|
405
|
+
@dataclass
|
|
406
|
+
class VersionNegotiationResult:
|
|
407
|
+
"""Result of version negotiation."""
|
|
408
|
+
|
|
409
|
+
agreed_version: StateFormatVersion
|
|
410
|
+
sdk_version: StateFormatVersion
|
|
411
|
+
peer_version: StateFormatVersion | None
|
|
412
|
+
is_compatible: bool
|
|
413
|
+
migration_required: bool
|
|
414
|
+
message: str
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class VersionNegotiator:
|
|
418
|
+
"""Negotiates state format version between SDK and Orchestrator.
|
|
419
|
+
|
|
420
|
+
Usage:
|
|
421
|
+
negotiator = VersionNegotiator()
|
|
422
|
+
|
|
423
|
+
# Check if we can read incoming state
|
|
424
|
+
result = negotiator.negotiate_read("2.0")
|
|
425
|
+
if result.is_compatible:
|
|
426
|
+
state = negotiator.deserialize(json_data)
|
|
427
|
+
|
|
428
|
+
# Serialize for target version
|
|
429
|
+
json_data = negotiator.serialize(state, target_version="2.0")
|
|
430
|
+
"""
|
|
431
|
+
|
|
432
|
+
def __init__(
|
|
433
|
+
self,
|
|
434
|
+
sdk_version: StateFormatVersion = SDK_STATE_VERSION,
|
|
435
|
+
min_version: StateFormatVersion = MIN_SUPPORTED_VERSION,
|
|
436
|
+
):
|
|
437
|
+
"""Initialize negotiator.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
sdk_version: This SDK's state format version
|
|
441
|
+
min_version: Minimum version this SDK can read
|
|
442
|
+
"""
|
|
443
|
+
self._sdk_version = sdk_version
|
|
444
|
+
self._min_version = min_version
|
|
445
|
+
self._serializers = {
|
|
446
|
+
StateFormatVersion.V1_0: StateSerializerV1(),
|
|
447
|
+
StateFormatVersion.V2_0: StateSerializerV2(),
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def sdk_version(self) -> StateFormatVersion:
|
|
452
|
+
"""Get SDK's state format version."""
|
|
453
|
+
return self._sdk_version
|
|
454
|
+
|
|
455
|
+
@property
|
|
456
|
+
def supported_versions(self) -> list[StateFormatVersion]:
|
|
457
|
+
"""Get list of supported versions."""
|
|
458
|
+
return list(StateFormatVersion)
|
|
459
|
+
|
|
460
|
+
def negotiate_read(
|
|
461
|
+
self,
|
|
462
|
+
peer_version: str | StateFormatVersion | None = None,
|
|
463
|
+
) -> VersionNegotiationResult:
|
|
464
|
+
"""Negotiate version for reading state from peer.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
peer_version: Version string from peer (e.g., "1.0", "2.0")
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
VersionNegotiationResult
|
|
471
|
+
"""
|
|
472
|
+
if peer_version is None:
|
|
473
|
+
# No version info, assume we can read with SDK version
|
|
474
|
+
return VersionNegotiationResult(
|
|
475
|
+
agreed_version=self._sdk_version,
|
|
476
|
+
sdk_version=self._sdk_version,
|
|
477
|
+
peer_version=None,
|
|
478
|
+
is_compatible=True,
|
|
479
|
+
migration_required=False,
|
|
480
|
+
message="No peer version provided, using SDK version",
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
if isinstance(peer_version, str):
|
|
484
|
+
try:
|
|
485
|
+
peer_version = StateFormatVersion.from_string(peer_version)
|
|
486
|
+
except ValueError:
|
|
487
|
+
return VersionNegotiationResult(
|
|
488
|
+
agreed_version=self._sdk_version,
|
|
489
|
+
sdk_version=self._sdk_version,
|
|
490
|
+
peer_version=None,
|
|
491
|
+
is_compatible=False,
|
|
492
|
+
migration_required=False,
|
|
493
|
+
message=f"Unknown peer version: {peer_version}",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Check compatibility
|
|
497
|
+
is_compatible = self._sdk_version.is_compatible_with(peer_version)
|
|
498
|
+
migration_required = peer_version != self._sdk_version
|
|
499
|
+
|
|
500
|
+
if is_compatible:
|
|
501
|
+
message = f"Compatible: SDK {self._sdk_version.value} can read {peer_version.value}"
|
|
502
|
+
else:
|
|
503
|
+
message = f"Incompatible: SDK {self._sdk_version.value} cannot read {peer_version.value}"
|
|
504
|
+
|
|
505
|
+
return VersionNegotiationResult(
|
|
506
|
+
agreed_version=peer_version if is_compatible else self._sdk_version,
|
|
507
|
+
sdk_version=self._sdk_version,
|
|
508
|
+
peer_version=peer_version,
|
|
509
|
+
is_compatible=is_compatible,
|
|
510
|
+
migration_required=migration_required,
|
|
511
|
+
message=message,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
def negotiate_write(
|
|
515
|
+
self,
|
|
516
|
+
target_version: str | StateFormatVersion | None = None,
|
|
517
|
+
) -> VersionNegotiationResult:
|
|
518
|
+
"""Negotiate version for writing state.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
target_version: Requested target version
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
VersionNegotiationResult
|
|
525
|
+
"""
|
|
526
|
+
if target_version is None:
|
|
527
|
+
# Use SDK's default version
|
|
528
|
+
return VersionNegotiationResult(
|
|
529
|
+
agreed_version=self._sdk_version,
|
|
530
|
+
sdk_version=self._sdk_version,
|
|
531
|
+
peer_version=None,
|
|
532
|
+
is_compatible=True,
|
|
533
|
+
migration_required=False,
|
|
534
|
+
message=f"Using SDK default version: {self._sdk_version.value}",
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
if isinstance(target_version, str):
|
|
538
|
+
try:
|
|
539
|
+
target_version = StateFormatVersion.from_string(target_version)
|
|
540
|
+
except ValueError:
|
|
541
|
+
return VersionNegotiationResult(
|
|
542
|
+
agreed_version=self._sdk_version,
|
|
543
|
+
sdk_version=self._sdk_version,
|
|
544
|
+
peer_version=None,
|
|
545
|
+
is_compatible=False,
|
|
546
|
+
migration_required=False,
|
|
547
|
+
message=f"Unknown target version: {target_version}",
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
# Check if we can write this version
|
|
551
|
+
can_write = target_version in self._serializers
|
|
552
|
+
|
|
553
|
+
return VersionNegotiationResult(
|
|
554
|
+
agreed_version=target_version if can_write else self._sdk_version,
|
|
555
|
+
sdk_version=self._sdk_version,
|
|
556
|
+
peer_version=target_version,
|
|
557
|
+
is_compatible=can_write,
|
|
558
|
+
migration_required=target_version != self._sdk_version,
|
|
559
|
+
message=f"{'Can' if can_write else 'Cannot'} write version {target_version.value}",
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
def serialize(
|
|
563
|
+
self,
|
|
564
|
+
state: VersionedState,
|
|
565
|
+
target_version: StateFormatVersion | str | None = None,
|
|
566
|
+
) -> str:
|
|
567
|
+
"""Serialize state to specified format version.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
state: State to serialize
|
|
571
|
+
target_version: Target format version (default: SDK version)
|
|
572
|
+
|
|
573
|
+
Returns:
|
|
574
|
+
JSON string in target format
|
|
575
|
+
|
|
576
|
+
Raises:
|
|
577
|
+
DoryStateError: If serialization fails
|
|
578
|
+
"""
|
|
579
|
+
if target_version is None:
|
|
580
|
+
target_version = self._sdk_version
|
|
581
|
+
elif isinstance(target_version, str):
|
|
582
|
+
target_version = StateFormatVersion.from_string(target_version)
|
|
583
|
+
|
|
584
|
+
serializer = self._serializers.get(target_version)
|
|
585
|
+
if not serializer:
|
|
586
|
+
raise DoryStateError(f"No serializer for version {target_version.value}")
|
|
587
|
+
|
|
588
|
+
return serializer.serialize(state)
|
|
589
|
+
|
|
590
|
+
def deserialize(self, data: str) -> VersionedState:
|
|
591
|
+
"""Deserialize state, auto-detecting format version.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
data: JSON string
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
VersionedState
|
|
598
|
+
|
|
599
|
+
Raises:
|
|
600
|
+
DoryStateError: If deserialization fails
|
|
601
|
+
"""
|
|
602
|
+
try:
|
|
603
|
+
parsed = json.loads(data)
|
|
604
|
+
except json.JSONDecodeError as e:
|
|
605
|
+
raise DoryStateError(f"Invalid JSON: {e}", cause=e)
|
|
606
|
+
|
|
607
|
+
version = detect_format_version(parsed)
|
|
608
|
+
|
|
609
|
+
serializer = self._serializers.get(version)
|
|
610
|
+
if not serializer:
|
|
611
|
+
raise DoryStateError(f"No deserializer for version {version.value}")
|
|
612
|
+
|
|
613
|
+
return serializer.deserialize(data)
|
|
614
|
+
|
|
615
|
+
def migrate(
|
|
616
|
+
self,
|
|
617
|
+
state: VersionedState,
|
|
618
|
+
target_version: StateFormatVersion,
|
|
619
|
+
) -> VersionedState:
|
|
620
|
+
"""Migrate state to a different format version.
|
|
621
|
+
|
|
622
|
+
This doesn't change the data, just updates the metadata
|
|
623
|
+
to indicate the new format version.
|
|
624
|
+
|
|
625
|
+
Args:
|
|
626
|
+
state: State to migrate
|
|
627
|
+
target_version: Target format version
|
|
628
|
+
|
|
629
|
+
Returns:
|
|
630
|
+
New VersionedState with updated version
|
|
631
|
+
"""
|
|
632
|
+
# Create new metadata with updated version
|
|
633
|
+
new_metadata = StateMetadata(
|
|
634
|
+
timestamp=state.metadata.timestamp,
|
|
635
|
+
timestamp_iso=state.metadata.timestamp_iso,
|
|
636
|
+
processor_id=state.metadata.processor_id,
|
|
637
|
+
pod_name=state.metadata.pod_name,
|
|
638
|
+
app_name=state.metadata.app_name,
|
|
639
|
+
restart_count=state.metadata.restart_count,
|
|
640
|
+
uptime_seconds=state.metadata.uptime_seconds,
|
|
641
|
+
request_count=state.metadata.request_count,
|
|
642
|
+
format_version=target_version.value,
|
|
643
|
+
sdk_version=state.metadata.sdk_version,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
return VersionedState(
|
|
647
|
+
data=state.data,
|
|
648
|
+
metadata=new_metadata,
|
|
649
|
+
checksum=state.checksum,
|
|
650
|
+
metrics=state.metrics,
|
|
651
|
+
connections=state.connections,
|
|
652
|
+
active_sessions=state.active_sessions,
|
|
653
|
+
session_data=state.session_data,
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
# =============================================================================
|
|
658
|
+
# Convenience Functions
|
|
659
|
+
# =============================================================================
|
|
660
|
+
|
|
661
|
+
# Global negotiator instance
|
|
662
|
+
_default_negotiator = VersionNegotiator()
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def get_sdk_version() -> str:
|
|
666
|
+
"""Get SDK's state format version string."""
|
|
667
|
+
return SDK_STATE_VERSION.value
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def get_supported_versions() -> list[str]:
|
|
671
|
+
"""Get list of supported version strings."""
|
|
672
|
+
return [v.value for v in StateFormatVersion]
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def is_version_supported(version: str) -> bool:
|
|
676
|
+
"""Check if a version is supported."""
|
|
677
|
+
try:
|
|
678
|
+
StateFormatVersion.from_string(version)
|
|
679
|
+
return True
|
|
680
|
+
except ValueError:
|
|
681
|
+
return False
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def serialize_state(
|
|
685
|
+
data: dict[str, Any],
|
|
686
|
+
processor_id: str,
|
|
687
|
+
pod_name: str = "",
|
|
688
|
+
target_version: str | None = None,
|
|
689
|
+
**kwargs: Any,
|
|
690
|
+
) -> str:
|
|
691
|
+
"""Serialize state data with version negotiation.
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
data: State data dictionary
|
|
695
|
+
processor_id: Processor identifier
|
|
696
|
+
pod_name: Pod name
|
|
697
|
+
target_version: Target format version (default: latest)
|
|
698
|
+
**kwargs: Additional metadata fields
|
|
699
|
+
|
|
700
|
+
Returns:
|
|
701
|
+
JSON string in target format
|
|
702
|
+
"""
|
|
703
|
+
metadata = StateMetadata(
|
|
704
|
+
processor_id=processor_id,
|
|
705
|
+
pod_name=pod_name,
|
|
706
|
+
app_name=kwargs.get("app_name", processor_id),
|
|
707
|
+
restart_count=kwargs.get("restart_count", 0),
|
|
708
|
+
uptime_seconds=kwargs.get("uptime_seconds", 0.0),
|
|
709
|
+
request_count=kwargs.get("request_count", 0),
|
|
710
|
+
sdk_version=kwargs.get("sdk_version", ""),
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
state = VersionedState(
|
|
714
|
+
data=data,
|
|
715
|
+
metadata=metadata,
|
|
716
|
+
metrics=kwargs.get("metrics", {}),
|
|
717
|
+
connections=kwargs.get("connections", []),
|
|
718
|
+
active_sessions=kwargs.get("active_sessions", 0),
|
|
719
|
+
session_data=kwargs.get("session_data", {}),
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
return _default_negotiator.serialize(state, target_version)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def deserialize_state(data: str) -> tuple[dict[str, Any], StateMetadata]:
|
|
726
|
+
"""Deserialize state data with version auto-detection.
|
|
727
|
+
|
|
728
|
+
Args:
|
|
729
|
+
data: JSON string
|
|
730
|
+
|
|
731
|
+
Returns:
|
|
732
|
+
Tuple of (state_data, metadata)
|
|
733
|
+
"""
|
|
734
|
+
state = _default_negotiator.deserialize(data)
|
|
735
|
+
return state.data, state.metadata
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def get_version_info() -> dict[str, Any]:
|
|
739
|
+
"""Get version information for health/status endpoints.
|
|
740
|
+
|
|
741
|
+
Returns:
|
|
742
|
+
Dictionary with version information
|
|
743
|
+
"""
|
|
744
|
+
return {
|
|
745
|
+
"state_format_version": SDK_STATE_VERSION.value,
|
|
746
|
+
"supported_versions": get_supported_versions(),
|
|
747
|
+
"min_supported_version": MIN_SUPPORTED_VERSION.value,
|
|
748
|
+
"latest_version": StateFormatVersion.latest().value,
|
|
749
|
+
}
|