dory-processor-sdk 0.0.1__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 +101 -0
- dory/auth/__init__.py +10 -0
- dory/auth/oauth2.py +153 -0
- dory/auto_instrument.py +142 -0
- dory/cli/__init__.py +5 -0
- dory/cli/main.py +137 -0
- dory/cli/templates.py +123 -0
- dory/config/__init__.py +23 -0
- dory/config/defaults.py +24 -0
- dory/config/loader.py +430 -0
- dory/config/presets.py +73 -0
- dory/config/schema.py +84 -0
- dory/core/__init__.py +27 -0
- dory/core/app.py +434 -0
- dory/core/context.py +209 -0
- dory/core/lifecycle.py +214 -0
- dory/core/meta.py +121 -0
- dory/core/modes.py +479 -0
- dory/core/processor.py +564 -0
- dory/core/signals.py +122 -0
- dory/decorators.py +142 -0
- dory/edge/__init__.py +88 -0
- dory/edge/adaptive.py +644 -0
- dory/edge/detector.py +546 -0
- dory/edge/fencing.py +488 -0
- dory/edge/heartbeat.py +598 -0
- dory/edge/role.py +419 -0
- dory/errors/__init__.py +139 -0
- dory/errors/classification.py +362 -0
- dory/errors/codes.py +498 -0
- dory/geo/__init__.py +40 -0
- dory/geo/geolocalizer.py +1034 -0
- dory/health/__init__.py +12 -0
- dory/health/probes.py +210 -0
- dory/health/server.py +635 -0
- dory/k8s/__init__.py +80 -0
- dory/k8s/annotation_watcher.py +184 -0
- dory/k8s/client.py +251 -0
- dory/k8s/labels.py +505 -0
- dory/k8s/pod_metadata.py +182 -0
- dory/logging/__init__.py +9 -0
- dory/logging/logger.py +148 -0
- dory/metrics/__init__.py +7 -0
- dory/metrics/collector.py +301 -0
- dory/middleware/__init__.py +46 -0
- dory/middleware/connection_tracker.py +608 -0
- dory/middleware/request_id.py +325 -0
- dory/middleware/request_tracker.py +511 -0
- dory/migration/__init__.py +33 -0
- dory/migration/configmap.py +232 -0
- dory/migration/s3_store.py +594 -0
- dory/migration/serialization.py +135 -0
- dory/migration/state_manager.py +286 -0
- dory/migration/transfer.py +382 -0
- dory/monitoring/__init__.py +29 -0
- dory/monitoring/opentelemetry.py +489 -0
- dory/output/__init__.py +31 -0
- dory/output/envelope.py +137 -0
- dory/output/formatter.py +113 -0
- dory/output/rabbitmq.py +632 -0
- dory/output/routing.py +318 -0
- dory/output/validator.py +199 -0
- dory/py.typed +2 -0
- dory/recovery/__init__.py +60 -0
- dory/recovery/golden_image.py +487 -0
- dory/recovery/golden_snapshot.py +713 -0
- dory/recovery/golden_validator.py +518 -0
- dory/recovery/partial_recovery.py +482 -0
- dory/recovery/recovery_decision.py +242 -0
- dory/recovery/restart_detector.py +142 -0
- dory/recovery/state_validator.py +183 -0
- dory/resilience/__init__.py +45 -0
- dory/resilience/circuit_breaker.py +457 -0
- dory/resilience/retry.py +389 -0
- dory/simple.py +342 -0
- dory/types.py +68 -0
- dory/utils/__init__.py +31 -0
- dory/utils/errors.py +59 -0
- dory/utils/retry.py +115 -0
- dory/utils/timeout.py +80 -0
- dory_processor_sdk-0.0.1.dist-info/METADATA +424 -0
- dory_processor_sdk-0.0.1.dist-info/RECORD +86 -0
- dory_processor_sdk-0.0.1.dist-info/WHEEL +5 -0
- dory_processor_sdk-0.0.1.dist-info/entry_points.txt +2 -0
- dory_processor_sdk-0.0.1.dist-info/licenses/LICENSE +201 -0
- dory_processor_sdk-0.0.1.dist-info/top_level.txt +1 -0
dory/output/routing.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Routing key builder utilities for topic-based message routing."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
# Base-32 alphabet used by standard geohash encoding
|
|
10
|
+
_GEOHASH_ALPHABET = "0123456789bcdefghjkmnpqrstuvwxyz"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _encode_geohash(latitude: float, longitude: float, precision: int = 9) -> str:
|
|
14
|
+
"""Encode latitude/longitude into a geohash string.
|
|
15
|
+
|
|
16
|
+
Uses the standard base-32 geohash algorithm (no external dependency).
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
latitude: Latitude in decimal degrees (-90 to 90).
|
|
20
|
+
longitude: Longitude in decimal degrees (-180 to 180).
|
|
21
|
+
precision: Number of characters in the geohash (default 9).
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Geohash string of the requested precision.
|
|
25
|
+
"""
|
|
26
|
+
lat_range = (-90.0, 90.0)
|
|
27
|
+
lon_range = (-180.0, 180.0)
|
|
28
|
+
bits = 0
|
|
29
|
+
char_index = 0
|
|
30
|
+
is_lon = True
|
|
31
|
+
result: list[str] = []
|
|
32
|
+
|
|
33
|
+
while len(result) < precision:
|
|
34
|
+
if is_lon:
|
|
35
|
+
mid = (lon_range[0] + lon_range[1]) / 2
|
|
36
|
+
if longitude >= mid:
|
|
37
|
+
char_index = (char_index << 1) | 1
|
|
38
|
+
lon_range = (mid, lon_range[1])
|
|
39
|
+
else:
|
|
40
|
+
char_index = char_index << 1
|
|
41
|
+
lon_range = (lon_range[0], mid)
|
|
42
|
+
else:
|
|
43
|
+
mid = (lat_range[0] + lat_range[1]) / 2
|
|
44
|
+
if latitude >= mid:
|
|
45
|
+
char_index = (char_index << 1) | 1
|
|
46
|
+
lat_range = (mid, lat_range[1])
|
|
47
|
+
else:
|
|
48
|
+
char_index = char_index << 1
|
|
49
|
+
lat_range = (lat_range[0], mid)
|
|
50
|
+
|
|
51
|
+
is_lon = not is_lon
|
|
52
|
+
bits += 1
|
|
53
|
+
|
|
54
|
+
if bits == 5:
|
|
55
|
+
result.append(_GEOHASH_ALPHABET[char_index])
|
|
56
|
+
bits = 0
|
|
57
|
+
char_index = 0
|
|
58
|
+
|
|
59
|
+
return "".join(result)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_geohash(precision: int = 9) -> Optional[str]:
|
|
63
|
+
"""Resolve geohash from environment variables.
|
|
64
|
+
|
|
65
|
+
Checks in order:
|
|
66
|
+
1. ``DORY_GEOHASH`` - direct geohash string (auto-injected by orchestrator
|
|
67
|
+
from sensors.location_point)
|
|
68
|
+
2. ``DORY_LATITUDE`` + ``DORY_LONGITUDE`` - computed from coordinates
|
|
69
|
+
(local development fallback)
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
precision: Geohash precision when computing from lat/lon (default 9).
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Geohash string, or None if not configured.
|
|
76
|
+
"""
|
|
77
|
+
# Option 1: direct geohash (auto-injected by orchestrator from sensors.location_point)
|
|
78
|
+
geohash = os.environ.get("DORY_GEOHASH", "").strip()
|
|
79
|
+
if geohash:
|
|
80
|
+
return geohash.lower()
|
|
81
|
+
|
|
82
|
+
# Option 2: compute from lat/lon (local development fallback)
|
|
83
|
+
lat_str = os.environ.get("DORY_LATITUDE", "").strip()
|
|
84
|
+
lon_str = os.environ.get("DORY_LONGITUDE", "").strip()
|
|
85
|
+
if lat_str and lon_str:
|
|
86
|
+
try:
|
|
87
|
+
lat = float(lat_str)
|
|
88
|
+
lon = float(lon_str)
|
|
89
|
+
except ValueError:
|
|
90
|
+
logger.warning(
|
|
91
|
+
"DORY_LATITUDE ('%s') and DORY_LONGITUDE ('%s') "
|
|
92
|
+
"must be valid decimal numbers — geohash disabled",
|
|
93
|
+
lat_str,
|
|
94
|
+
lon_str,
|
|
95
|
+
)
|
|
96
|
+
return None
|
|
97
|
+
return _encode_geohash(lat, lon, precision)
|
|
98
|
+
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Sentinel: distinguishes "not yet resolved" from "resolved to None"
|
|
103
|
+
_UNRESOLVED = object()
|
|
104
|
+
|
|
105
|
+
# Cached geohash — resolved once per process
|
|
106
|
+
_cached_geohash: object = _UNRESOLVED
|
|
107
|
+
|
|
108
|
+
# Cached processor_id — resolved once per process
|
|
109
|
+
_cached_processor_id: object = _UNRESOLVED
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _resolve_processor_id() -> Optional[str]:
|
|
113
|
+
"""Resolve processor_id from the ``PROCESSOR_ID`` environment variable.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Processor ID string, or None if not configured.
|
|
117
|
+
"""
|
|
118
|
+
pid = os.environ.get("PROCESSOR_ID", "").strip()
|
|
119
|
+
if pid:
|
|
120
|
+
return pid
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_processor_id() -> Optional[str]:
|
|
125
|
+
"""Get the processor_id for this deployment, resolved from environment.
|
|
126
|
+
|
|
127
|
+
The result is cached after the first call. Returns None if not configured.
|
|
128
|
+
|
|
129
|
+
In production the orchestrator auto-injects ``PROCESSOR_ID`` from
|
|
130
|
+
``processors.id``. For local development you can set ``PROCESSOR_ID``
|
|
131
|
+
manually.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Processor ID string, or None if not configured.
|
|
135
|
+
"""
|
|
136
|
+
global _cached_processor_id
|
|
137
|
+
if _cached_processor_id is _UNRESOLVED:
|
|
138
|
+
_cached_processor_id = _resolve_processor_id()
|
|
139
|
+
if _cached_processor_id:
|
|
140
|
+
logger.info("Processor ID resolved: %s", _cached_processor_id)
|
|
141
|
+
else:
|
|
142
|
+
logger.warning(
|
|
143
|
+
"No processor_id configured (PROCESSOR_ID not set). "
|
|
144
|
+
"Routing keys will not include processor_id prefix."
|
|
145
|
+
)
|
|
146
|
+
return _cached_processor_id
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_geohash(precision: int = 9) -> Optional[str]:
|
|
150
|
+
"""Get the geohash for this deployment, resolved from environment.
|
|
151
|
+
|
|
152
|
+
The result is cached after the first call. Returns None if no geohash
|
|
153
|
+
is configured (sensor has no location_point).
|
|
154
|
+
|
|
155
|
+
In production the orchestrator auto-injects ``DORY_GEOHASH`` from the
|
|
156
|
+
sensor's ``location_point`` column. For local development you can set
|
|
157
|
+
``DORY_GEOHASH`` or ``DORY_LATITUDE`` + ``DORY_LONGITUDE`` manually.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
precision: Geohash precision when computing from lat/lon.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Geohash string, or None if not configured.
|
|
164
|
+
"""
|
|
165
|
+
global _cached_geohash
|
|
166
|
+
if _cached_geohash is _UNRESOLVED:
|
|
167
|
+
_cached_geohash = _resolve_geohash(precision)
|
|
168
|
+
if _cached_geohash:
|
|
169
|
+
logger.info("Geohash resolved: %s", _cached_geohash)
|
|
170
|
+
else:
|
|
171
|
+
logger.warning(
|
|
172
|
+
"No geohash configured (DORY_GEOHASH not set). "
|
|
173
|
+
"Routing keys will not include geohash segments."
|
|
174
|
+
)
|
|
175
|
+
return _cached_geohash
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def build_routing_key_from_geo(
|
|
179
|
+
event_type: str,
|
|
180
|
+
latitude: float,
|
|
181
|
+
longitude: float,
|
|
182
|
+
*,
|
|
183
|
+
precision: int = 9,
|
|
184
|
+
segment_length: int = 1,
|
|
185
|
+
) -> str:
|
|
186
|
+
"""Build a routing key from event type and explicit geo coordinates.
|
|
187
|
+
|
|
188
|
+
Converts the latitude/longitude to a geohash and appends it as
|
|
189
|
+
dot-separated segments to the event type. If ``DORY_PROCESSOR_ID``
|
|
190
|
+
is set, it is prepended as the first segment.
|
|
191
|
+
|
|
192
|
+
Produces: ``<processor_id>.<event_type>.<geohash_segments...>`` (with processor_id)
|
|
193
|
+
Produces: ``<event_type>.<geohash_segments...>`` (without processor_id)
|
|
194
|
+
|
|
195
|
+
Examples:
|
|
196
|
+
>>> build_routing_key_from_geo("accident", 39.1, -84.5)
|
|
197
|
+
'accident.d.h.z.0.6.m.b.8.e'
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
event_type: Event type (e.g., "accident", "detection").
|
|
201
|
+
latitude: Latitude in decimal degrees (-90 to 90).
|
|
202
|
+
longitude: Longitude in decimal degrees (-180 to 180).
|
|
203
|
+
precision: Geohash precision (default 9).
|
|
204
|
+
segment_length: Geohash characters per routing segment (default 1).
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Dot-separated routing key.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
ValueError: If event_type is empty, segment_length < 1, or
|
|
211
|
+
coordinates are out of range.
|
|
212
|
+
"""
|
|
213
|
+
event_type = event_type.strip()
|
|
214
|
+
if not event_type:
|
|
215
|
+
raise ValueError("event_type must not be empty")
|
|
216
|
+
if segment_length < 1:
|
|
217
|
+
raise ValueError("segment_length must be >= 1")
|
|
218
|
+
if not (-90.0 <= latitude <= 90.0):
|
|
219
|
+
raise ValueError(f"latitude must be between -90 and 90, got {latitude}")
|
|
220
|
+
if not (-180.0 <= longitude <= 180.0):
|
|
221
|
+
raise ValueError(f"longitude must be between -180 and 180, got {longitude}")
|
|
222
|
+
|
|
223
|
+
geohash = _encode_geohash(latitude, longitude, precision)
|
|
224
|
+
segments = [
|
|
225
|
+
geohash[i : i + segment_length]
|
|
226
|
+
for i in range(0, len(geohash), segment_length)
|
|
227
|
+
]
|
|
228
|
+
processor_id = get_processor_id()
|
|
229
|
+
parts = [processor_id, event_type, *segments] if processor_id else [event_type, *segments]
|
|
230
|
+
key = ".".join(parts)
|
|
231
|
+
logger.debug(
|
|
232
|
+
"build_routing_key_from_geo: processor_id=%s event_type=%s lat=%.6f lon=%.6f geohash=%s routing_key=%s",
|
|
233
|
+
processor_id,
|
|
234
|
+
event_type,
|
|
235
|
+
latitude,
|
|
236
|
+
longitude,
|
|
237
|
+
geohash,
|
|
238
|
+
key,
|
|
239
|
+
)
|
|
240
|
+
return key
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def build_routing_key(
|
|
244
|
+
event_type: str,
|
|
245
|
+
segment_length: int = 1,
|
|
246
|
+
) -> str:
|
|
247
|
+
"""Build a routing key from just an event type.
|
|
248
|
+
|
|
249
|
+
The processor_id is automatically resolved from ``PROCESSOR_ID``
|
|
250
|
+
(auto-injected by the orchestrator from ``processors.id``). The geohash
|
|
251
|
+
is resolved from ``DORY_GEOHASH`` (auto-injected from
|
|
252
|
+
``sensors.location_point``). Both prefixes are optional — if either is
|
|
253
|
+
missing the routing key omits that segment.
|
|
254
|
+
|
|
255
|
+
Produces (all present): ``<processor_id>.<event_type>.<geohash_segments...>``
|
|
256
|
+
Produces (no processor): ``<event_type>.<geohash_segments...>``
|
|
257
|
+
Produces (no geohash): ``<processor_id>.<event_type>``
|
|
258
|
+
Produces (neither): ``<event_type>``
|
|
259
|
+
|
|
260
|
+
Examples:
|
|
261
|
+
# With PROCESSOR_ID=abc-123 and DORY_GEOHASH=dhz06mb8e
|
|
262
|
+
>>> build_routing_key("accident")
|
|
263
|
+
'abc-123.accident.d.h.z.0.6.m.b.8.e'
|
|
264
|
+
|
|
265
|
+
# With only DORY_GEOHASH=dhz06mb8e
|
|
266
|
+
>>> build_routing_key("accident")
|
|
267
|
+
'accident.d.h.z.0.6.m.b.8.e'
|
|
268
|
+
|
|
269
|
+
# With neither (local dev, processor not in k8s)
|
|
270
|
+
>>> build_routing_key("accident")
|
|
271
|
+
'accident'
|
|
272
|
+
|
|
273
|
+
Subscriber binding examples (when processor_id is in the key)::
|
|
274
|
+
|
|
275
|
+
*.accident.# -> all accidents from any processor
|
|
276
|
+
<pid>.accident.# -> accidents from one specific processor
|
|
277
|
+
*.accident.d.h.z.# -> accidents in geohash prefix "dhz"
|
|
278
|
+
*.*.d.h.z.0.6.# -> any event in a narrower area
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
event_type: Event type (e.g., "accident", "detection", "alert").
|
|
282
|
+
segment_length: Geohash characters per routing segment (default 1).
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Dot-separated routing key.
|
|
286
|
+
|
|
287
|
+
Raises:
|
|
288
|
+
ValueError: If event_type is empty or segment_length < 1.
|
|
289
|
+
"""
|
|
290
|
+
event_type = event_type.strip()
|
|
291
|
+
if not event_type:
|
|
292
|
+
raise ValueError("event_type must not be empty")
|
|
293
|
+
|
|
294
|
+
if segment_length < 1:
|
|
295
|
+
raise ValueError("segment_length must be >= 1")
|
|
296
|
+
|
|
297
|
+
processor_id = get_processor_id()
|
|
298
|
+
geohash = get_geohash()
|
|
299
|
+
|
|
300
|
+
parts: list[str] = []
|
|
301
|
+
if processor_id:
|
|
302
|
+
parts.append(processor_id)
|
|
303
|
+
parts.append(event_type)
|
|
304
|
+
if geohash:
|
|
305
|
+
parts.extend(
|
|
306
|
+
geohash[i : i + segment_length]
|
|
307
|
+
for i in range(0, len(geohash), segment_length)
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
key = ".".join(parts)
|
|
311
|
+
logger.debug(
|
|
312
|
+
"build_routing_key: processor_id=%s event_type=%s geohash=%s routing_key=%s",
|
|
313
|
+
processor_id,
|
|
314
|
+
event_type,
|
|
315
|
+
geohash,
|
|
316
|
+
key,
|
|
317
|
+
)
|
|
318
|
+
return key
|
dory/output/validator.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Envelope validator for subscriber-side schema version dispatch.
|
|
3
|
+
|
|
4
|
+
Provides version-aware message handling so subscribers can process
|
|
5
|
+
messages from publishers running different schema versions.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
validator = EnvelopeValidator()
|
|
9
|
+
|
|
10
|
+
# Register handlers per schema version
|
|
11
|
+
validator.register("0.1", handle_v0)
|
|
12
|
+
validator.register("1.0", handle_v1)
|
|
13
|
+
|
|
14
|
+
# Process incoming message
|
|
15
|
+
result = await validator.handle(raw_message_dict)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Any, Callable, Awaitable
|
|
20
|
+
|
|
21
|
+
from dory.output.envelope import (
|
|
22
|
+
MessageEnvelope,
|
|
23
|
+
ENVELOPE_SCHEMA_VERSION,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Type for async handler functions
|
|
29
|
+
HandlerFunc = Callable[[MessageEnvelope], Awaitable[Any]]
|
|
30
|
+
|
|
31
|
+
# Type for sync handler functions
|
|
32
|
+
SyncHandlerFunc = Callable[[MessageEnvelope], Any]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UnsupportedVersionError(Exception):
|
|
36
|
+
"""Raised when no handler is registered for a schema version."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, version: str, available: list[str]):
|
|
39
|
+
self.version = version
|
|
40
|
+
self.available = available
|
|
41
|
+
super().__init__(
|
|
42
|
+
f"No handler for schema_version={version}. "
|
|
43
|
+
f"Available: {available}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class EnvelopeValidator:
|
|
48
|
+
"""Subscriber-side envelope validator with version dispatch.
|
|
49
|
+
|
|
50
|
+
Validates incoming envelopes and routes to the appropriate handler
|
|
51
|
+
based on schema_version.
|
|
52
|
+
|
|
53
|
+
Version matching strategy:
|
|
54
|
+
1. Exact match (e.g., "0.1" -> handler for "0.1")
|
|
55
|
+
2. Major version fallback (e.g., "0.3" -> handler for "0.1")
|
|
56
|
+
3. UnsupportedVersionError if no match
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
strict_version: If True, require exact version match (no fallback).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
strict_version: bool = False,
|
|
65
|
+
**kwargs: Any,
|
|
66
|
+
):
|
|
67
|
+
self._handlers: dict[str, HandlerFunc] = {}
|
|
68
|
+
self._sync_handlers: dict[str, SyncHandlerFunc] = {}
|
|
69
|
+
self._strict_version = strict_version
|
|
70
|
+
|
|
71
|
+
def register(
|
|
72
|
+
self,
|
|
73
|
+
version: str,
|
|
74
|
+
handler: HandlerFunc | SyncHandlerFunc,
|
|
75
|
+
is_async: bool = True,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Register a handler for a schema version.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
version: Schema version string (e.g., "0.1", "1.0").
|
|
81
|
+
handler: Async or sync callable that receives a MessageEnvelope.
|
|
82
|
+
is_async: Whether the handler is async (default True).
|
|
83
|
+
"""
|
|
84
|
+
if is_async:
|
|
85
|
+
self._handlers[version] = handler # type: ignore[assignment]
|
|
86
|
+
else:
|
|
87
|
+
self._sync_handlers[version] = handler # type: ignore[assignment]
|
|
88
|
+
logger.debug(f"Registered {'async' if is_async else 'sync'} handler for v{version}")
|
|
89
|
+
|
|
90
|
+
def unregister(self, version: str) -> None:
|
|
91
|
+
"""Remove a handler for a schema version."""
|
|
92
|
+
self._handlers.pop(version, None)
|
|
93
|
+
self._sync_handlers.pop(version, None)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def registered_versions(self) -> list[str]:
|
|
97
|
+
"""List all registered version strings."""
|
|
98
|
+
versions = set(self._handlers.keys()) | set(self._sync_handlers.keys())
|
|
99
|
+
return sorted(versions)
|
|
100
|
+
|
|
101
|
+
def validate(self, data: dict[str, Any]) -> MessageEnvelope:
|
|
102
|
+
"""Validate and parse raw message data into a MessageEnvelope.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
data: Raw dictionary from deserialized message.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Parsed MessageEnvelope.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
ValueError: If required envelope fields are missing.
|
|
112
|
+
"""
|
|
113
|
+
# Check for envelope structure
|
|
114
|
+
if "schema_version" not in data:
|
|
115
|
+
raise ValueError("Missing required field: schema_version")
|
|
116
|
+
if "payload" not in data:
|
|
117
|
+
raise ValueError("Missing required field: payload")
|
|
118
|
+
|
|
119
|
+
envelope = MessageEnvelope.from_dict(data)
|
|
120
|
+
|
|
121
|
+
return envelope
|
|
122
|
+
|
|
123
|
+
def resolve_handler(self, version: str) -> tuple[Any, bool]:
|
|
124
|
+
"""Find the best matching handler for a version.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
version: Schema version string.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Tuple of (handler, is_async).
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
UnsupportedVersionError: If no handler matches.
|
|
134
|
+
"""
|
|
135
|
+
# 1. Exact match in async handlers
|
|
136
|
+
if version in self._handlers:
|
|
137
|
+
return self._handlers[version], True
|
|
138
|
+
|
|
139
|
+
# 2. Exact match in sync handlers
|
|
140
|
+
if version in self._sync_handlers:
|
|
141
|
+
return self._sync_handlers[version], False
|
|
142
|
+
|
|
143
|
+
# 3. Major version fallback (if not strict)
|
|
144
|
+
if not self._strict_version:
|
|
145
|
+
try:
|
|
146
|
+
major = version.split(".")[0]
|
|
147
|
+
major_key = f"{major}.0"
|
|
148
|
+
|
|
149
|
+
if major_key in self._handlers:
|
|
150
|
+
logger.debug(
|
|
151
|
+
f"Using major version fallback: v{version} -> v{major_key}"
|
|
152
|
+
)
|
|
153
|
+
return self._handlers[major_key], True
|
|
154
|
+
|
|
155
|
+
if major_key in self._sync_handlers:
|
|
156
|
+
logger.debug(
|
|
157
|
+
f"Using major version fallback: v{version} -> v{major_key}"
|
|
158
|
+
)
|
|
159
|
+
return self._sync_handlers[major_key], False
|
|
160
|
+
except (ValueError, IndexError):
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
raise UnsupportedVersionError(version, self.registered_versions)
|
|
164
|
+
|
|
165
|
+
async def handle(self, data: dict[str, Any]) -> Any:
|
|
166
|
+
"""Validate an envelope and dispatch to the registered handler.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
data: Raw dictionary from deserialized message.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Result from the handler.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
ValueError: If envelope is invalid.
|
|
176
|
+
UnsupportedVersionError: If no handler for the version.
|
|
177
|
+
"""
|
|
178
|
+
envelope = self.validate(data)
|
|
179
|
+
handler, is_async = self.resolve_handler(envelope.schema_version)
|
|
180
|
+
|
|
181
|
+
if is_async:
|
|
182
|
+
return await handler(envelope)
|
|
183
|
+
else:
|
|
184
|
+
return handler(envelope)
|
|
185
|
+
|
|
186
|
+
def can_handle(self, version: str) -> bool:
|
|
187
|
+
"""Check if a handler exists for this version (including fallback).
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
version: Schema version string.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
True if a handler would be found for this version.
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
self.resolve_handler(version)
|
|
197
|
+
return True
|
|
198
|
+
except UnsupportedVersionError:
|
|
199
|
+
return False
|
dory/py.typed
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Recovery and fault handling modules."""
|
|
2
|
+
|
|
3
|
+
from dory.recovery.restart_detector import RestartDetector, RestartInfo
|
|
4
|
+
from dory.recovery.state_validator import StateValidator
|
|
5
|
+
from dory.recovery.golden_image import GoldenImageManager, ResetLevel, ResetResult, CacheResetManager
|
|
6
|
+
from dory.recovery.recovery_decision import RecoveryDecisionMaker, RecoveryDecision
|
|
7
|
+
from dory.recovery.golden_snapshot import (
|
|
8
|
+
GoldenSnapshotManager,
|
|
9
|
+
Snapshot,
|
|
10
|
+
SnapshotMetadata,
|
|
11
|
+
SnapshotStorageError,
|
|
12
|
+
SnapshotValidationError,
|
|
13
|
+
SnapshotFormat,
|
|
14
|
+
)
|
|
15
|
+
from dory.recovery.golden_validator import (
|
|
16
|
+
GoldenValidator,
|
|
17
|
+
ValidationResult,
|
|
18
|
+
ValidationIssue,
|
|
19
|
+
ValidationSeverity,
|
|
20
|
+
)
|
|
21
|
+
from dory.recovery.partial_recovery import (
|
|
22
|
+
PartialRecoveryManager,
|
|
23
|
+
RecoveryResult as PartialRecoveryResult,
|
|
24
|
+
FieldRecovery,
|
|
25
|
+
FieldStatus,
|
|
26
|
+
numeric_recovery_strategy,
|
|
27
|
+
string_recovery_strategy,
|
|
28
|
+
list_recovery_strategy,
|
|
29
|
+
dict_recovery_strategy,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"RestartDetector",
|
|
34
|
+
"RestartInfo",
|
|
35
|
+
"StateValidator",
|
|
36
|
+
"GoldenImageManager",
|
|
37
|
+
"ResetLevel",
|
|
38
|
+
"ResetResult",
|
|
39
|
+
"CacheResetManager",
|
|
40
|
+
"RecoveryDecisionMaker",
|
|
41
|
+
"RecoveryDecision",
|
|
42
|
+
"GoldenSnapshotManager",
|
|
43
|
+
"Snapshot",
|
|
44
|
+
"SnapshotMetadata",
|
|
45
|
+
"SnapshotStorageError",
|
|
46
|
+
"SnapshotValidationError",
|
|
47
|
+
"SnapshotFormat",
|
|
48
|
+
"GoldenValidator",
|
|
49
|
+
"ValidationResult",
|
|
50
|
+
"ValidationIssue",
|
|
51
|
+
"ValidationSeverity",
|
|
52
|
+
"PartialRecoveryManager",
|
|
53
|
+
"PartialRecoveryResult",
|
|
54
|
+
"FieldRecovery",
|
|
55
|
+
"FieldStatus",
|
|
56
|
+
"numeric_recovery_strategy",
|
|
57
|
+
"string_recovery_strategy",
|
|
58
|
+
"list_recovery_strategy",
|
|
59
|
+
"dict_recovery_strategy",
|
|
60
|
+
]
|