dory-sdk 2.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.
- dory/__init__.py +70 -0
- dory/auto_instrument.py +142 -0
- dory/cli/__init__.py +5 -0
- dory/cli/main.py +290 -0
- dory/cli/templates.py +333 -0
- dory/config/__init__.py +23 -0
- dory/config/defaults.py +50 -0
- dory/config/loader.py +361 -0
- dory/config/presets.py +325 -0
- dory/config/schema.py +152 -0
- dory/core/__init__.py +27 -0
- dory/core/app.py +404 -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 +654 -0
- dory/core/signals.py +122 -0
- dory/decorators.py +142 -0
- dory/errors/__init__.py +117 -0
- dory/errors/classification.py +362 -0
- dory/errors/codes.py +495 -0
- dory/health/__init__.py +10 -0
- dory/health/probes.py +210 -0
- dory/health/server.py +306 -0
- dory/k8s/__init__.py +11 -0
- dory/k8s/annotation_watcher.py +184 -0
- dory/k8s/client.py +251 -0
- dory/k8s/pod_metadata.py +182 -0
- dory/logging/__init__.py +9 -0
- dory/logging/logger.py +175 -0
- dory/metrics/__init__.py +7 -0
- dory/metrics/collector.py +301 -0
- dory/middleware/__init__.py +36 -0
- dory/middleware/connection_tracker.py +608 -0
- dory/middleware/request_id.py +321 -0
- dory/middleware/request_tracker.py +501 -0
- dory/migration/__init__.py +11 -0
- dory/migration/configmap.py +260 -0
- dory/migration/serialization.py +167 -0
- dory/migration/state_manager.py +301 -0
- dory/monitoring/__init__.py +23 -0
- dory/monitoring/opentelemetry.py +462 -0
- dory/py.typed +2 -0
- dory/recovery/__init__.py +60 -0
- dory/recovery/golden_image.py +480 -0
- dory/recovery/golden_snapshot.py +561 -0
- dory/recovery/golden_validator.py +518 -0
- dory/recovery/partial_recovery.py +479 -0
- dory/recovery/recovery_decision.py +242 -0
- dory/recovery/restart_detector.py +142 -0
- dory/recovery/state_validator.py +187 -0
- dory/resilience/__init__.py +45 -0
- dory/resilience/circuit_breaker.py +454 -0
- dory/resilience/retry.py +389 -0
- dory/sidecar/__init__.py +6 -0
- dory/sidecar/main.py +75 -0
- dory/sidecar/server.py +329 -0
- dory/simple.py +342 -0
- dory/types.py +75 -0
- dory/utils/__init__.py +25 -0
- dory/utils/errors.py +59 -0
- dory/utils/retry.py +115 -0
- dory/utils/timeout.py +80 -0
- dory_sdk-2.1.0.dist-info/METADATA +663 -0
- dory_sdk-2.1.0.dist-info/RECORD +69 -0
- dory_sdk-2.1.0.dist-info/WHEEL +5 -0
- dory_sdk-2.1.0.dist-info/entry_points.txt +3 -0
- dory_sdk-2.1.0.dist-info/top_level.txt +1 -0
dory/sidecar/server.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Sidecar health server that proxies health checks for non-SDK apps."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
from aiohttp import web
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("dory.sidecar")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class SidecarConfig:
|
|
18
|
+
"""Configuration for the sidecar server."""
|
|
19
|
+
|
|
20
|
+
# Sidecar health server port
|
|
21
|
+
health_port: int = 8080
|
|
22
|
+
|
|
23
|
+
# Main app configuration (optional - for health forwarding)
|
|
24
|
+
app_port: Optional[int] = None
|
|
25
|
+
app_host: str = "localhost"
|
|
26
|
+
app_health_path: Optional[str] = None # e.g., "/health"
|
|
27
|
+
app_ready_path: Optional[str] = None # e.g., "/ready"
|
|
28
|
+
app_prestop_path: Optional[str] = None # e.g., "/shutdown"
|
|
29
|
+
|
|
30
|
+
# Timeouts
|
|
31
|
+
app_check_timeout: float = 2.0
|
|
32
|
+
prestop_timeout: float = 25.0
|
|
33
|
+
|
|
34
|
+
# Behavior
|
|
35
|
+
ready_requires_app: bool = False # If True, /ready fails if app doesn't respond
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_env(cls) -> "SidecarConfig":
|
|
39
|
+
"""Load configuration from environment variables."""
|
|
40
|
+
return cls(
|
|
41
|
+
health_port=int(os.getenv("DORY_HEALTH_PORT", "8080")),
|
|
42
|
+
app_port=int(os.getenv("DORY_APP_PORT")) if os.getenv("DORY_APP_PORT") else None,
|
|
43
|
+
app_host=os.getenv("DORY_APP_HOST", "localhost"),
|
|
44
|
+
app_health_path=os.getenv("DORY_APP_HEALTH_PATH"),
|
|
45
|
+
app_ready_path=os.getenv("DORY_APP_READY_PATH"),
|
|
46
|
+
app_prestop_path=os.getenv("DORY_APP_PRESTOP_PATH"),
|
|
47
|
+
app_check_timeout=float(os.getenv("DORY_APP_CHECK_TIMEOUT", "2.0")),
|
|
48
|
+
prestop_timeout=float(os.getenv("DORY_PRESTOP_TIMEOUT", "25.0")),
|
|
49
|
+
ready_requires_app=os.getenv("DORY_READY_REQUIRES_APP", "false").lower() == "true",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SidecarServer:
|
|
54
|
+
"""
|
|
55
|
+
Lightweight sidecar server that provides Kubernetes health endpoints.
|
|
56
|
+
|
|
57
|
+
This allows apps without SDK integration to run in Dory by providing
|
|
58
|
+
the required health endpoints. Apps won't get state migration benefits,
|
|
59
|
+
but they will run normally.
|
|
60
|
+
|
|
61
|
+
Features:
|
|
62
|
+
- Always responds to /healthz (sidecar is alive)
|
|
63
|
+
- Optionally checks main app for /ready
|
|
64
|
+
- Optionally calls main app on /prestop
|
|
65
|
+
- Exposes basic /metrics
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, config: Optional[SidecarConfig] = None):
|
|
69
|
+
self.config = config or SidecarConfig.from_env()
|
|
70
|
+
self._app: Optional[web.Application] = None
|
|
71
|
+
self._runner: Optional[web.AppRunner] = None
|
|
72
|
+
self._start_time = time.time()
|
|
73
|
+
self._ready = True
|
|
74
|
+
self._shutting_down = False
|
|
75
|
+
self._request_count = 0
|
|
76
|
+
self._app_check_failures = 0
|
|
77
|
+
|
|
78
|
+
async def _check_app_endpoint(self, path: str) -> tuple[bool, str]:
|
|
79
|
+
"""Check if the main app responds on the given path."""
|
|
80
|
+
if not self.config.app_port:
|
|
81
|
+
return True, "No app port configured"
|
|
82
|
+
|
|
83
|
+
url = f"http://{self.config.app_host}:{self.config.app_port}{path}"
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
timeout = aiohttp.ClientTimeout(total=self.config.app_check_timeout)
|
|
87
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
88
|
+
async with session.get(url) as response:
|
|
89
|
+
if response.status < 400:
|
|
90
|
+
return True, f"App responded with {response.status}"
|
|
91
|
+
else:
|
|
92
|
+
return False, f"App responded with {response.status}"
|
|
93
|
+
except asyncio.TimeoutError:
|
|
94
|
+
return False, "App health check timed out"
|
|
95
|
+
except aiohttp.ClientError as e:
|
|
96
|
+
return False, f"App connection failed: {e}"
|
|
97
|
+
except Exception as e:
|
|
98
|
+
return False, f"App check error: {e}"
|
|
99
|
+
|
|
100
|
+
async def _call_app_prestop(self) -> tuple[bool, str]:
|
|
101
|
+
"""Call the main app's prestop endpoint if configured."""
|
|
102
|
+
if not self.config.app_port or not self.config.app_prestop_path:
|
|
103
|
+
return True, "No app prestop path configured"
|
|
104
|
+
|
|
105
|
+
url = f"http://{self.config.app_host}:{self.config.app_port}{self.config.app_prestop_path}"
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
timeout = aiohttp.ClientTimeout(total=self.config.prestop_timeout)
|
|
109
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
110
|
+
async with session.get(url) as response:
|
|
111
|
+
body = await response.text()
|
|
112
|
+
return response.status < 400, f"App prestop returned {response.status}: {body}"
|
|
113
|
+
except Exception as e:
|
|
114
|
+
return False, f"App prestop failed: {e}"
|
|
115
|
+
|
|
116
|
+
async def handle_healthz(self, request: web.Request) -> web.Response:
|
|
117
|
+
"""
|
|
118
|
+
Liveness probe - returns 200 if sidecar is running.
|
|
119
|
+
|
|
120
|
+
This always succeeds because if we can respond, we're alive.
|
|
121
|
+
The main app's health is checked separately via /ready if configured.
|
|
122
|
+
"""
|
|
123
|
+
self._request_count += 1
|
|
124
|
+
|
|
125
|
+
return web.json_response({
|
|
126
|
+
"status": "healthy",
|
|
127
|
+
"sidecar": True,
|
|
128
|
+
"uptime_seconds": int(time.time() - self._start_time),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
async def handle_ready(self, request: web.Request) -> web.Response:
|
|
132
|
+
"""
|
|
133
|
+
Readiness probe - optionally checks main app health.
|
|
134
|
+
|
|
135
|
+
Behavior depends on configuration:
|
|
136
|
+
- If app_ready_path is set, checks that endpoint
|
|
137
|
+
- If app_health_path is set (and ready_path not), checks health endpoint
|
|
138
|
+
- If app_port is set (no paths), checks if port is accepting connections
|
|
139
|
+
- If ready_requires_app=true, fails if app check fails
|
|
140
|
+
- Otherwise, always returns ready
|
|
141
|
+
"""
|
|
142
|
+
self._request_count += 1
|
|
143
|
+
|
|
144
|
+
if self._shutting_down:
|
|
145
|
+
return web.json_response(
|
|
146
|
+
{"status": "shutting_down", "ready": False},
|
|
147
|
+
status=503
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Determine which path to check
|
|
151
|
+
check_path = self.config.app_ready_path or self.config.app_health_path
|
|
152
|
+
|
|
153
|
+
app_ok = True
|
|
154
|
+
app_message = "No app check configured"
|
|
155
|
+
|
|
156
|
+
if self.config.app_port:
|
|
157
|
+
if check_path:
|
|
158
|
+
app_ok, app_message = await self._check_app_endpoint(check_path)
|
|
159
|
+
else:
|
|
160
|
+
# Just check if port is open
|
|
161
|
+
try:
|
|
162
|
+
_, writer = await asyncio.wait_for(
|
|
163
|
+
asyncio.open_connection(self.config.app_host, self.config.app_port),
|
|
164
|
+
timeout=self.config.app_check_timeout
|
|
165
|
+
)
|
|
166
|
+
writer.close()
|
|
167
|
+
await writer.wait_closed()
|
|
168
|
+
app_ok = True
|
|
169
|
+
app_message = "App port is accepting connections"
|
|
170
|
+
except Exception as e:
|
|
171
|
+
app_ok = False
|
|
172
|
+
app_message = f"App port not responding: {e}"
|
|
173
|
+
|
|
174
|
+
if not app_ok:
|
|
175
|
+
self._app_check_failures += 1
|
|
176
|
+
|
|
177
|
+
# Decide if we should report not ready
|
|
178
|
+
if not app_ok and self.config.ready_requires_app:
|
|
179
|
+
return web.json_response(
|
|
180
|
+
{
|
|
181
|
+
"status": "not_ready",
|
|
182
|
+
"ready": False,
|
|
183
|
+
"app_status": app_message,
|
|
184
|
+
"sidecar": True,
|
|
185
|
+
},
|
|
186
|
+
status=503
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return web.json_response({
|
|
190
|
+
"status": "ready",
|
|
191
|
+
"ready": True,
|
|
192
|
+
"app_status": app_message,
|
|
193
|
+
"app_ok": app_ok,
|
|
194
|
+
"sidecar": True,
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
async def handle_prestop(self, request: web.Request) -> web.Response:
|
|
198
|
+
"""
|
|
199
|
+
PreStop hook handler - signals graceful shutdown.
|
|
200
|
+
|
|
201
|
+
If app_prestop_path is configured, calls that endpoint to give
|
|
202
|
+
the main app a chance to clean up. Otherwise just marks as shutting down.
|
|
203
|
+
|
|
204
|
+
Note: Without SDK integration, no state will be saved.
|
|
205
|
+
"""
|
|
206
|
+
self._request_count += 1
|
|
207
|
+
self._shutting_down = True
|
|
208
|
+
self._ready = False
|
|
209
|
+
|
|
210
|
+
logger.info("PreStop hook called - beginning graceful shutdown")
|
|
211
|
+
|
|
212
|
+
# Call app's prestop if configured
|
|
213
|
+
app_prestop_ok, app_prestop_message = await self._call_app_prestop()
|
|
214
|
+
|
|
215
|
+
if not app_prestop_ok:
|
|
216
|
+
logger.warning(f"App prestop failed: {app_prestop_message}")
|
|
217
|
+
|
|
218
|
+
return web.json_response({
|
|
219
|
+
"status": "shutting_down",
|
|
220
|
+
"app_prestop": app_prestop_message,
|
|
221
|
+
"state_saved": False, # No state save without SDK
|
|
222
|
+
"sidecar": True,
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
async def handle_metrics(self, request: web.Request) -> web.Response:
|
|
226
|
+
"""Prometheus metrics endpoint."""
|
|
227
|
+
self._request_count += 1
|
|
228
|
+
|
|
229
|
+
uptime = time.time() - self._start_time
|
|
230
|
+
|
|
231
|
+
metrics = f"""# HELP dory_sidecar_up Sidecar is running
|
|
232
|
+
# TYPE dory_sidecar_up gauge
|
|
233
|
+
dory_sidecar_up 1
|
|
234
|
+
|
|
235
|
+
# HELP dory_sidecar_uptime_seconds Sidecar uptime in seconds
|
|
236
|
+
# TYPE dory_sidecar_uptime_seconds gauge
|
|
237
|
+
dory_sidecar_uptime_seconds {uptime:.2f}
|
|
238
|
+
|
|
239
|
+
# HELP dory_sidecar_requests_total Total requests handled
|
|
240
|
+
# TYPE dory_sidecar_requests_total counter
|
|
241
|
+
dory_sidecar_requests_total {self._request_count}
|
|
242
|
+
|
|
243
|
+
# HELP dory_sidecar_app_check_failures_total App health check failures
|
|
244
|
+
# TYPE dory_sidecar_app_check_failures_total counter
|
|
245
|
+
dory_sidecar_app_check_failures_total {self._app_check_failures}
|
|
246
|
+
|
|
247
|
+
# HELP dory_sidecar_shutting_down Sidecar is shutting down
|
|
248
|
+
# TYPE dory_sidecar_shutting_down gauge
|
|
249
|
+
dory_sidecar_shutting_down {1 if self._shutting_down else 0}
|
|
250
|
+
|
|
251
|
+
# HELP dory_sidecar_ready Sidecar ready status
|
|
252
|
+
# TYPE dory_sidecar_ready gauge
|
|
253
|
+
dory_sidecar_ready {1 if self._ready and not self._shutting_down else 0}
|
|
254
|
+
"""
|
|
255
|
+
return web.Response(text=metrics, content_type="text/plain")
|
|
256
|
+
|
|
257
|
+
async def handle_state(self, request: web.Request) -> web.Response:
|
|
258
|
+
"""
|
|
259
|
+
State endpoint - returns empty state for non-SDK apps.
|
|
260
|
+
|
|
261
|
+
This endpoint exists for compatibility but returns no state
|
|
262
|
+
since the app doesn't use the SDK for state management.
|
|
263
|
+
"""
|
|
264
|
+
self._request_count += 1
|
|
265
|
+
|
|
266
|
+
if request.method == "GET":
|
|
267
|
+
return web.json_response({
|
|
268
|
+
"state": None,
|
|
269
|
+
"message": "No state available - app does not use Dory SDK",
|
|
270
|
+
"sidecar": True,
|
|
271
|
+
})
|
|
272
|
+
elif request.method == "POST":
|
|
273
|
+
return web.json_response({
|
|
274
|
+
"restored": False,
|
|
275
|
+
"message": "Cannot restore state - app does not use Dory SDK",
|
|
276
|
+
"sidecar": True,
|
|
277
|
+
})
|
|
278
|
+
else:
|
|
279
|
+
return web.json_response(
|
|
280
|
+
{"error": "Method not allowed"},
|
|
281
|
+
status=405
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def _create_app(self) -> web.Application:
|
|
285
|
+
"""Create the aiohttp application."""
|
|
286
|
+
app = web.Application()
|
|
287
|
+
|
|
288
|
+
app.router.add_get("/healthz", self.handle_healthz)
|
|
289
|
+
app.router.add_get("/ready", self.handle_ready)
|
|
290
|
+
app.router.add_get("/prestop", self.handle_prestop)
|
|
291
|
+
app.router.add_get("/metrics", self.handle_metrics)
|
|
292
|
+
app.router.add_get("/state", self.handle_state)
|
|
293
|
+
app.router.add_post("/state", self.handle_state)
|
|
294
|
+
|
|
295
|
+
return app
|
|
296
|
+
|
|
297
|
+
async def start(self) -> None:
|
|
298
|
+
"""Start the sidecar server."""
|
|
299
|
+
self._app = self._create_app()
|
|
300
|
+
self._runner = web.AppRunner(self._app)
|
|
301
|
+
await self._runner.setup()
|
|
302
|
+
|
|
303
|
+
site = web.TCPSite(self._runner, "0.0.0.0", self.config.health_port)
|
|
304
|
+
await site.start()
|
|
305
|
+
|
|
306
|
+
logger.info(f"Dory sidecar started on port {self.config.health_port}")
|
|
307
|
+
|
|
308
|
+
if self.config.app_port:
|
|
309
|
+
logger.info(f"Monitoring app on {self.config.app_host}:{self.config.app_port}")
|
|
310
|
+
else:
|
|
311
|
+
logger.info("No app port configured - running in standalone mode")
|
|
312
|
+
|
|
313
|
+
async def stop(self) -> None:
|
|
314
|
+
"""Stop the sidecar server."""
|
|
315
|
+
if self._runner:
|
|
316
|
+
await self._runner.cleanup()
|
|
317
|
+
logger.info("Dory sidecar stopped")
|
|
318
|
+
|
|
319
|
+
async def run_forever(self) -> None:
|
|
320
|
+
"""Run the sidecar server until interrupted."""
|
|
321
|
+
await self.start()
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
while True:
|
|
325
|
+
await asyncio.sleep(3600)
|
|
326
|
+
except asyncio.CancelledError:
|
|
327
|
+
pass
|
|
328
|
+
finally:
|
|
329
|
+
await self.stop()
|
dory/simple.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simple function-based API for Dory processors.
|
|
3
|
+
|
|
4
|
+
For applications that don't need the full class-based API,
|
|
5
|
+
this module provides a simpler decorator-based approach.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from dory.simple import processor, state
|
|
9
|
+
|
|
10
|
+
counter = state(0)
|
|
11
|
+
|
|
12
|
+
@processor
|
|
13
|
+
async def main(ctx):
|
|
14
|
+
async for _ in ctx.run_loop(interval=1):
|
|
15
|
+
counter.value += 1
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import inspect
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any, Callable, Awaitable, TypeVar, Generic
|
|
22
|
+
|
|
23
|
+
from dory.core.app import DoryApp
|
|
24
|
+
from dory.core.processor import BaseProcessor
|
|
25
|
+
from dory.core.context import ExecutionContext
|
|
26
|
+
|
|
27
|
+
T = TypeVar('T')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class StateVar(Generic[T]):
|
|
32
|
+
"""
|
|
33
|
+
A state variable for function-based processors.
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
counter = state(0)
|
|
37
|
+
sessions = state(dict) # Factory for mutable defaults
|
|
38
|
+
|
|
39
|
+
@processor
|
|
40
|
+
async def main(ctx):
|
|
41
|
+
counter.value += 1
|
|
42
|
+
sessions.value["user1"] = data
|
|
43
|
+
"""
|
|
44
|
+
_default: T | Callable[[], T]
|
|
45
|
+
_value: T = field(default=None, init=False, repr=False)
|
|
46
|
+
_initialized: bool = field(default=False, init=False)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def value(self) -> T:
|
|
50
|
+
"""Get the current value."""
|
|
51
|
+
if not self._initialized:
|
|
52
|
+
if callable(self._default):
|
|
53
|
+
self._value = self._default()
|
|
54
|
+
else:
|
|
55
|
+
self._value = self._default
|
|
56
|
+
self._initialized = True
|
|
57
|
+
return self._value
|
|
58
|
+
|
|
59
|
+
@value.setter
|
|
60
|
+
def value(self, new_value: T) -> None:
|
|
61
|
+
"""Set a new value."""
|
|
62
|
+
self._value = new_value
|
|
63
|
+
self._initialized = True
|
|
64
|
+
|
|
65
|
+
def reset(self) -> None:
|
|
66
|
+
"""Reset to default value."""
|
|
67
|
+
self._initialized = False
|
|
68
|
+
|
|
69
|
+
def get(self) -> T:
|
|
70
|
+
"""Alias for value property."""
|
|
71
|
+
return self.value
|
|
72
|
+
|
|
73
|
+
def set(self, new_value: T) -> None:
|
|
74
|
+
"""Alias for value setter."""
|
|
75
|
+
self.value = new_value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def state(default: T | Callable[[], T] = None) -> StateVar[T]:
|
|
79
|
+
"""
|
|
80
|
+
Create a state variable for function-based processors.
|
|
81
|
+
|
|
82
|
+
State variables are automatically saved and restored during migrations.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
default: Default value or factory function for mutable defaults
|
|
86
|
+
|
|
87
|
+
Usage:
|
|
88
|
+
# Simple values
|
|
89
|
+
counter = state(0)
|
|
90
|
+
name = state("default")
|
|
91
|
+
|
|
92
|
+
# Mutable values (use factory)
|
|
93
|
+
data = state(dict) # Creates new dict each time
|
|
94
|
+
items = state(list) # Creates new list each time
|
|
95
|
+
|
|
96
|
+
@processor
|
|
97
|
+
async def main(ctx):
|
|
98
|
+
counter.value += 1
|
|
99
|
+
data.value["key"] = "value"
|
|
100
|
+
"""
|
|
101
|
+
return StateVar(_default=default)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Global registry of state variables for current processor
|
|
105
|
+
_state_registry: dict[str, StateVar] = {}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _register_state(name: str, var: StateVar) -> None:
|
|
109
|
+
"""Register a state variable (called by processor decorator)."""
|
|
110
|
+
_state_registry[name] = var
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _get_all_state() -> dict[str, Any]:
|
|
114
|
+
"""Get all state values."""
|
|
115
|
+
return {name: var.value for name, var in _state_registry.items()}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _set_all_state(values: dict[str, Any]) -> None:
|
|
119
|
+
"""Set all state values."""
|
|
120
|
+
for name, value in values.items():
|
|
121
|
+
if name in _state_registry:
|
|
122
|
+
_state_registry[name].value = value
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class FunctionProcessor(BaseProcessor):
|
|
126
|
+
"""
|
|
127
|
+
Wrapper that converts a function into a BaseProcessor.
|
|
128
|
+
|
|
129
|
+
Internal use by @processor decorator.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
func: Callable[[ExecutionContext], Awaitable[None]],
|
|
135
|
+
state_vars: dict[str, StateVar],
|
|
136
|
+
context: ExecutionContext | None = None,
|
|
137
|
+
):
|
|
138
|
+
super().__init__(context)
|
|
139
|
+
self._func = func
|
|
140
|
+
self._state_vars = state_vars
|
|
141
|
+
|
|
142
|
+
async def run(self) -> None:
|
|
143
|
+
"""Run the wrapped function."""
|
|
144
|
+
await self._func(self.context)
|
|
145
|
+
|
|
146
|
+
def get_state(self) -> dict:
|
|
147
|
+
"""Get state from registered state variables."""
|
|
148
|
+
return {name: var.value for name, var in self._state_vars.items()}
|
|
149
|
+
|
|
150
|
+
async def restore_state(self, state: dict) -> None:
|
|
151
|
+
"""Restore state to registered state variables."""
|
|
152
|
+
for name, value in state.items():
|
|
153
|
+
if name in self._state_vars:
|
|
154
|
+
self._state_vars[name].value = value
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ContextWrapper:
|
|
158
|
+
"""
|
|
159
|
+
Wrapper around ExecutionContext with additional helpers for function-based API.
|
|
160
|
+
|
|
161
|
+
Provides run_loop() and other conveniences directly on ctx.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, context: ExecutionContext):
|
|
165
|
+
self._context = context
|
|
166
|
+
|
|
167
|
+
def __getattr__(self, name: str) -> Any:
|
|
168
|
+
"""Delegate to underlying context."""
|
|
169
|
+
return getattr(self._context, name)
|
|
170
|
+
|
|
171
|
+
async def run_loop(
|
|
172
|
+
self,
|
|
173
|
+
interval: float = 1.0,
|
|
174
|
+
check_migration: bool = True,
|
|
175
|
+
):
|
|
176
|
+
"""
|
|
177
|
+
Async iterator that yields until shutdown is requested.
|
|
178
|
+
|
|
179
|
+
Usage:
|
|
180
|
+
@processor
|
|
181
|
+
async def main(ctx):
|
|
182
|
+
async for i in ctx.run_loop(interval=1):
|
|
183
|
+
counter.value += 1
|
|
184
|
+
"""
|
|
185
|
+
iteration = 0
|
|
186
|
+
while not self._context.is_shutdown_requested():
|
|
187
|
+
yield iteration
|
|
188
|
+
iteration += 1
|
|
189
|
+
|
|
190
|
+
if check_migration and self._context.is_migration_imminent():
|
|
191
|
+
self._context.logger().info(
|
|
192
|
+
f"Migration imminent, completing iteration {iteration}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
await asyncio.sleep(interval)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def shutdown_requested(self) -> bool:
|
|
199
|
+
"""Check if shutdown is requested (simpler than is_shutdown_requested())."""
|
|
200
|
+
return self._context.is_shutdown_requested()
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def migration_imminent(self) -> bool:
|
|
204
|
+
"""Check if migration is imminent (simpler than is_migration_imminent())."""
|
|
205
|
+
return self._context.is_migration_imminent()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def processor(
|
|
209
|
+
func: Callable[[ContextWrapper], Awaitable[None]] | None = None,
|
|
210
|
+
*,
|
|
211
|
+
config_file: str | None = None,
|
|
212
|
+
log_level: str | None = None,
|
|
213
|
+
):
|
|
214
|
+
"""
|
|
215
|
+
Decorator to create a Dory processor from a simple async function.
|
|
216
|
+
|
|
217
|
+
This is the simplest way to create a Dory processor. Just decorate
|
|
218
|
+
your main async function and it handles everything else.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
func: The async function to wrap
|
|
222
|
+
config_file: Optional path to config file
|
|
223
|
+
log_level: Optional log level override
|
|
224
|
+
|
|
225
|
+
Usage:
|
|
226
|
+
# Minimal stateless processor
|
|
227
|
+
from dory.simple import processor
|
|
228
|
+
|
|
229
|
+
@processor
|
|
230
|
+
async def main(ctx):
|
|
231
|
+
while not ctx.shutdown_requested:
|
|
232
|
+
print("Working...")
|
|
233
|
+
await asyncio.sleep(1)
|
|
234
|
+
|
|
235
|
+
# With state
|
|
236
|
+
from dory.simple import processor, state
|
|
237
|
+
|
|
238
|
+
counter = state(0)
|
|
239
|
+
data = state(dict)
|
|
240
|
+
|
|
241
|
+
@processor
|
|
242
|
+
async def main(ctx):
|
|
243
|
+
async for i in ctx.run_loop(interval=1):
|
|
244
|
+
counter.value += 1
|
|
245
|
+
print(f"Count: {counter.value}")
|
|
246
|
+
|
|
247
|
+
# With config
|
|
248
|
+
@processor(config_file="dory.yaml", log_level="DEBUG")
|
|
249
|
+
async def main(ctx):
|
|
250
|
+
...
|
|
251
|
+
"""
|
|
252
|
+
def decorator(fn: Callable[[ContextWrapper], Awaitable[None]]):
|
|
253
|
+
# Collect state variables from the module
|
|
254
|
+
frame = inspect.currentframe()
|
|
255
|
+
if frame and frame.f_back and frame.f_back.f_back:
|
|
256
|
+
module_globals = frame.f_back.f_back.f_globals
|
|
257
|
+
state_vars = {
|
|
258
|
+
name: var
|
|
259
|
+
for name, var in module_globals.items()
|
|
260
|
+
if isinstance(var, StateVar)
|
|
261
|
+
}
|
|
262
|
+
else:
|
|
263
|
+
state_vars = {}
|
|
264
|
+
|
|
265
|
+
# Create wrapper function that wraps context
|
|
266
|
+
async def wrapped_func(context: ExecutionContext) -> None:
|
|
267
|
+
wrapper = ContextWrapper(context)
|
|
268
|
+
await fn(wrapper)
|
|
269
|
+
|
|
270
|
+
# Create processor class dynamically
|
|
271
|
+
class DynamicProcessor(FunctionProcessor):
|
|
272
|
+
def __init__(self, context: ExecutionContext | None = None):
|
|
273
|
+
super().__init__(wrapped_func, state_vars, context)
|
|
274
|
+
|
|
275
|
+
# Run immediately if this is the main module
|
|
276
|
+
if frame and frame.f_back and frame.f_back.f_back:
|
|
277
|
+
if module_globals.get("__name__") == "__main__":
|
|
278
|
+
DoryApp(
|
|
279
|
+
config_file=config_file,
|
|
280
|
+
log_level=log_level,
|
|
281
|
+
).run(DynamicProcessor)
|
|
282
|
+
|
|
283
|
+
return fn
|
|
284
|
+
|
|
285
|
+
if func is not None:
|
|
286
|
+
# Called without arguments: @processor
|
|
287
|
+
return decorator(func)
|
|
288
|
+
else:
|
|
289
|
+
# Called with arguments: @processor(config_file="...")
|
|
290
|
+
return decorator
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def run_processor(
|
|
294
|
+
func: Callable[[ContextWrapper], Awaitable[None]],
|
|
295
|
+
*,
|
|
296
|
+
config_file: str | None = None,
|
|
297
|
+
log_level: str | None = None,
|
|
298
|
+
) -> None:
|
|
299
|
+
"""
|
|
300
|
+
Run a function as a Dory processor.
|
|
301
|
+
|
|
302
|
+
Alternative to @processor decorator when you want explicit control.
|
|
303
|
+
|
|
304
|
+
Usage:
|
|
305
|
+
from dory.simple import run_processor, state
|
|
306
|
+
|
|
307
|
+
counter = state(0)
|
|
308
|
+
|
|
309
|
+
async def main(ctx):
|
|
310
|
+
async for _ in ctx.run_loop(interval=1):
|
|
311
|
+
counter.value += 1
|
|
312
|
+
|
|
313
|
+
if __name__ == "__main__":
|
|
314
|
+
run_processor(main)
|
|
315
|
+
"""
|
|
316
|
+
# Collect state variables from caller's module
|
|
317
|
+
frame = inspect.currentframe()
|
|
318
|
+
if frame and frame.f_back:
|
|
319
|
+
module_globals = frame.f_back.f_globals
|
|
320
|
+
state_vars = {
|
|
321
|
+
name: var
|
|
322
|
+
for name, var in module_globals.items()
|
|
323
|
+
if isinstance(var, StateVar)
|
|
324
|
+
}
|
|
325
|
+
else:
|
|
326
|
+
state_vars = {}
|
|
327
|
+
|
|
328
|
+
# Create wrapper function
|
|
329
|
+
async def wrapped_func(context: ExecutionContext) -> None:
|
|
330
|
+
wrapper = ContextWrapper(context)
|
|
331
|
+
await func(wrapper)
|
|
332
|
+
|
|
333
|
+
# Create processor class
|
|
334
|
+
class DynamicProcessor(FunctionProcessor):
|
|
335
|
+
def __init__(self, context: ExecutionContext | None = None):
|
|
336
|
+
super().__init__(wrapped_func, state_vars, context)
|
|
337
|
+
|
|
338
|
+
# Run
|
|
339
|
+
DoryApp(
|
|
340
|
+
config_file=config_file,
|
|
341
|
+
log_level=log_level,
|
|
342
|
+
).run(DynamicProcessor)
|