ls-algorithm-plugin-sdk 0.2.5__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.
- algorithm_plugin_sdk/__init__.py +52 -0
- algorithm_plugin_sdk/algorithm.py +33 -0
- algorithm_plugin_sdk/cli.py +542 -0
- algorithm_plugin_sdk/context.py +307 -0
- algorithm_plugin_sdk/deployment.py +317 -0
- algorithm_plugin_sdk/errors.py +27 -0
- algorithm_plugin_sdk/examples/__init__.py +1 -0
- algorithm_plugin_sdk/examples/example_algorithm.py +119 -0
- algorithm_plugin_sdk/examples/simulated_algorithm.py +92 -0
- algorithm_plugin_sdk/loader.py +71 -0
- algorithm_plugin_sdk/models.py +322 -0
- algorithm_plugin_sdk/registration.py +282 -0
- algorithm_plugin_sdk/release.py +171 -0
- algorithm_plugin_sdk/runner.py +114 -0
- algorithm_plugin_sdk/service.py +664 -0
- algorithm_plugin_sdk/webui/__init__.py +1 -0
- algorithm_plugin_sdk/webui/app.css +49 -0
- algorithm_plugin_sdk/webui/app.js +197 -0
- algorithm_plugin_sdk/webui/index.html +62 -0
- algorithm_plugin_sdk/webui_app.py +43 -0
- ls_algorithm_plugin_sdk-0.2.5.dist-info/METADATA +87 -0
- ls_algorithm_plugin_sdk-0.2.5.dist-info/RECORD +25 -0
- ls_algorithm_plugin_sdk-0.2.5.dist-info/WHEEL +5 -0
- ls_algorithm_plugin_sdk-0.2.5.dist-info/entry_points.txt +2 -0
- ls_algorithm_plugin_sdk-0.2.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import random
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from dataclasses import dataclass, field, replace
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
from .context import ExecutionContext, ProgressSnapshot, utc_now
|
|
16
|
+
from .errors import (ExecutionCancelled, ExecutionNotFinished,
|
|
17
|
+
ExecutionNotFound, IdempotencyConflict)
|
|
18
|
+
from .models import AlgorithmRequest, AlgorithmResult
|
|
19
|
+
from .release import ReleaseManifest
|
|
20
|
+
from .runner import AlgorithmRunner
|
|
21
|
+
from .webui_app import announce_webui, mount_webui
|
|
22
|
+
|
|
23
|
+
TERMINAL_STATES = {"succeeded", "partial", "failed", "cancelled"}
|
|
24
|
+
MAX_ATTEMPTS = 10
|
|
25
|
+
RETRY_SECONDS = 1.0
|
|
26
|
+
RETRY_JITTER_RATIO = 0.25
|
|
27
|
+
|
|
28
|
+
RunnerFactory = Callable[[], AlgorithmRunner]
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class ExecutionRecord:
|
|
35
|
+
execution_id: str
|
|
36
|
+
request: AlgorithmRequest
|
|
37
|
+
accepted_at: str
|
|
38
|
+
state: str = "accepted"
|
|
39
|
+
started_at: str | None = None
|
|
40
|
+
finished_at: str | None = None
|
|
41
|
+
result: AlgorithmResult | None = None
|
|
42
|
+
error: str | None = None
|
|
43
|
+
context: ExecutionContext | None = None
|
|
44
|
+
future: Future[None] | None = None
|
|
45
|
+
attempt: int = 0
|
|
46
|
+
max_attempts: int = 1
|
|
47
|
+
events: list[dict[str, Any]] = field(default_factory=list)
|
|
48
|
+
condition: threading.Condition = field(
|
|
49
|
+
default_factory=lambda: threading.Condition(threading.RLock())
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def add_progress(self, snapshot: ProgressSnapshot) -> None:
|
|
53
|
+
with self.condition:
|
|
54
|
+
self.events.append(snapshot.to_dict())
|
|
55
|
+
self.condition.notify_all()
|
|
56
|
+
|
|
57
|
+
def set_state(
|
|
58
|
+
self,
|
|
59
|
+
state: str,
|
|
60
|
+
*,
|
|
61
|
+
result: AlgorithmResult | None = None,
|
|
62
|
+
error: str | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
with self.condition:
|
|
65
|
+
self.state = state
|
|
66
|
+
self.result = result
|
|
67
|
+
self.error = error
|
|
68
|
+
if state == "running":
|
|
69
|
+
self.started_at = utc_now()
|
|
70
|
+
if state in TERMINAL_STATES:
|
|
71
|
+
self.finished_at = utc_now()
|
|
72
|
+
self.condition.notify_all()
|
|
73
|
+
|
|
74
|
+
def status_payload(self) -> dict[str, Any]:
|
|
75
|
+
with self.condition:
|
|
76
|
+
progress = self.context.snapshot().to_dict() if self.context else None
|
|
77
|
+
return {
|
|
78
|
+
"executionId": self.execution_id,
|
|
79
|
+
"state": self.state,
|
|
80
|
+
"acceptedAt": self.accepted_at,
|
|
81
|
+
"startedAt": self.started_at,
|
|
82
|
+
"finishedAt": self.finished_at,
|
|
83
|
+
"error": self.error,
|
|
84
|
+
"attempt": self.attempt,
|
|
85
|
+
"maxAttempts": self.max_attempts,
|
|
86
|
+
"progress": progress,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
def events_after(self, sequence: int, wait_seconds: float) -> dict[str, Any]:
|
|
90
|
+
deadline = time.monotonic() + wait_seconds
|
|
91
|
+
with self.condition:
|
|
92
|
+
while (
|
|
93
|
+
not any(event["sequence"] > sequence for event in self.events)
|
|
94
|
+
and self.state not in TERMINAL_STATES
|
|
95
|
+
and time.monotonic() < deadline
|
|
96
|
+
):
|
|
97
|
+
self.condition.wait(timeout=max(0.0, deadline - time.monotonic()))
|
|
98
|
+
events = [
|
|
99
|
+
event for event in self.events if event["sequence"] > sequence
|
|
100
|
+
]
|
|
101
|
+
next_sequence = events[-1]["sequence"] if events else sequence
|
|
102
|
+
return {
|
|
103
|
+
"events": events,
|
|
104
|
+
"nextSequence": next_sequence,
|
|
105
|
+
"terminal": self.state in TERMINAL_STATES,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ExecutionManager:
|
|
110
|
+
"""Protocol-independent background execution service."""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
runner: AlgorithmRunner,
|
|
115
|
+
*,
|
|
116
|
+
max_concurrent_executions: int = 1,
|
|
117
|
+
scratch_dir: str | None = None,
|
|
118
|
+
gpu_ids: list[int] | None = None,
|
|
119
|
+
runner_factory: RunnerFactory | None = None,
|
|
120
|
+
max_attempts: int = MAX_ATTEMPTS,
|
|
121
|
+
retry_seconds: float = RETRY_SECONDS,
|
|
122
|
+
retry_jitter_ratio: float = RETRY_JITTER_RATIO,
|
|
123
|
+
release_manifest: ReleaseManifest | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
if max_concurrent_executions < 1:
|
|
126
|
+
raise ValueError("max_concurrent_executions must be positive")
|
|
127
|
+
if max_attempts < 1:
|
|
128
|
+
raise ValueError("max_attempts must be positive")
|
|
129
|
+
if retry_seconds <= 0:
|
|
130
|
+
raise ValueError("retry_seconds must be positive")
|
|
131
|
+
if not 0 <= retry_jitter_ratio <= 1:
|
|
132
|
+
raise ValueError("retry_jitter_ratio must be in [0, 1]")
|
|
133
|
+
configured_gpu_ids = None if gpu_ids is None else list(gpu_ids)
|
|
134
|
+
if configured_gpu_ids is not None and (
|
|
135
|
+
any(
|
|
136
|
+
isinstance(gpu_id, bool) or not isinstance(gpu_id, int) or gpu_id < 0
|
|
137
|
+
for gpu_id in configured_gpu_ids
|
|
138
|
+
)
|
|
139
|
+
or len(configured_gpu_ids) != len(set(configured_gpu_ids))
|
|
140
|
+
):
|
|
141
|
+
raise ValueError("gpu_ids must be unique non-negative integers")
|
|
142
|
+
self.runner = runner
|
|
143
|
+
algorithm_class = type(runner.algorithm)
|
|
144
|
+
self._runner_factory = runner_factory or (
|
|
145
|
+
lambda: AlgorithmRunner(algorithm_class())
|
|
146
|
+
)
|
|
147
|
+
self.scratch_dir = scratch_dir
|
|
148
|
+
self.gpu_ids = configured_gpu_ids
|
|
149
|
+
self.max_concurrent_executions = max_concurrent_executions
|
|
150
|
+
self.max_attempts = max_attempts
|
|
151
|
+
self.retry_seconds = retry_seconds
|
|
152
|
+
self.retry_jitter_ratio = retry_jitter_ratio
|
|
153
|
+
self.release_manifest = release_manifest
|
|
154
|
+
self._executor = ThreadPoolExecutor(
|
|
155
|
+
max_workers=max_concurrent_executions,
|
|
156
|
+
thread_name_prefix="algorithm-execution",
|
|
157
|
+
)
|
|
158
|
+
self._records: dict[str, ExecutionRecord] = {}
|
|
159
|
+
self._idempotency: dict[str, tuple[str, str]] = {}
|
|
160
|
+
self._lock = threading.RLock()
|
|
161
|
+
self._started_at = time.monotonic()
|
|
162
|
+
self._started = False
|
|
163
|
+
self._closed = False
|
|
164
|
+
self._runner_condition = threading.Condition(threading.RLock())
|
|
165
|
+
self._runner_generation = 0
|
|
166
|
+
self._runner_users = 0
|
|
167
|
+
self._restart_pending = False
|
|
168
|
+
|
|
169
|
+
def start(self) -> None:
|
|
170
|
+
with self._lock:
|
|
171
|
+
if self._closed:
|
|
172
|
+
raise RuntimeError("execution manager is closed")
|
|
173
|
+
if self._started:
|
|
174
|
+
return
|
|
175
|
+
self.runner.start()
|
|
176
|
+
self._started = True
|
|
177
|
+
|
|
178
|
+
def submit(
|
|
179
|
+
self,
|
|
180
|
+
request: AlgorithmRequest,
|
|
181
|
+
*,
|
|
182
|
+
idempotency_key: str | None = None,
|
|
183
|
+
) -> dict[str, Any]:
|
|
184
|
+
if self.gpu_ids is not None:
|
|
185
|
+
request = replace(request, gpu_ids=self.gpu_ids)
|
|
186
|
+
self.start()
|
|
187
|
+
canonical_request = json.dumps(
|
|
188
|
+
request.to_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
189
|
+
)
|
|
190
|
+
with self._lock:
|
|
191
|
+
if idempotency_key:
|
|
192
|
+
existing = self._idempotency.get(idempotency_key)
|
|
193
|
+
if existing is not None:
|
|
194
|
+
existing_request, execution_id = existing
|
|
195
|
+
if existing_request != canonical_request:
|
|
196
|
+
raise IdempotencyConflict(idempotency_key)
|
|
197
|
+
record = self._records[execution_id]
|
|
198
|
+
return {
|
|
199
|
+
"executionId": execution_id,
|
|
200
|
+
"state": record.state,
|
|
201
|
+
"acceptedAt": record.accepted_at,
|
|
202
|
+
}
|
|
203
|
+
execution_id = f"exec-{uuid.uuid4().hex}"
|
|
204
|
+
record = ExecutionRecord(
|
|
205
|
+
execution_id=execution_id,
|
|
206
|
+
request=request,
|
|
207
|
+
accepted_at=utc_now(),
|
|
208
|
+
max_attempts=self.max_attempts,
|
|
209
|
+
)
|
|
210
|
+
record.context = ExecutionContext(
|
|
211
|
+
execution_id,
|
|
212
|
+
[item.dataset for item in request.inputs],
|
|
213
|
+
scratch_dir=(
|
|
214
|
+
record.request.workspace["scratchRoot"]
|
|
215
|
+
if record.request.workspace is not None
|
|
216
|
+
else self.scratch_dir
|
|
217
|
+
),
|
|
218
|
+
progress_callback=record.add_progress,
|
|
219
|
+
)
|
|
220
|
+
with self._lock:
|
|
221
|
+
if self._closed:
|
|
222
|
+
raise RuntimeError("execution manager is closed")
|
|
223
|
+
self._records[execution_id] = record
|
|
224
|
+
if idempotency_key:
|
|
225
|
+
self._idempotency[idempotency_key] = (
|
|
226
|
+
canonical_request,
|
|
227
|
+
execution_id,
|
|
228
|
+
)
|
|
229
|
+
record.future = self._executor.submit(self._run, record)
|
|
230
|
+
return {
|
|
231
|
+
"executionId": execution_id,
|
|
232
|
+
"state": record.state,
|
|
233
|
+
"acceptedAt": record.accepted_at,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
def manifest(self) -> dict[str, Any]:
|
|
237
|
+
metadata = self.runner.algorithm.metadata()
|
|
238
|
+
capabilities = {
|
|
239
|
+
"cli": True,
|
|
240
|
+
"webui": True,
|
|
241
|
+
"httpService": True,
|
|
242
|
+
"cancel": True,
|
|
243
|
+
"idempotency": True,
|
|
244
|
+
}
|
|
245
|
+
if self.release_manifest is not None:
|
|
246
|
+
manifest = self.release_manifest.plugin_manifest(
|
|
247
|
+
max_concurrency=self.max_concurrent_executions,
|
|
248
|
+
capabilities=capabilities,
|
|
249
|
+
)
|
|
250
|
+
manifest["metadata"] = metadata.to_dict()
|
|
251
|
+
return manifest
|
|
252
|
+
return {
|
|
253
|
+
"apiVersion": "ldp.algorithm/v1",
|
|
254
|
+
"algorithmType": metadata.name.replace("-", "_"),
|
|
255
|
+
"name": metadata.name,
|
|
256
|
+
"version": metadata.version,
|
|
257
|
+
"inputSchema": "algorithm-plugin-sdk.request/v1",
|
|
258
|
+
"outputSchema": "algorithm-plugin-sdk.result/v1",
|
|
259
|
+
"maxConcurrency": self.max_concurrent_executions,
|
|
260
|
+
"capabilities": capabilities,
|
|
261
|
+
"metadata": metadata.to_dict(),
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
def heartbeat(self) -> dict[str, Any]:
|
|
265
|
+
with self._lock:
|
|
266
|
+
active = [
|
|
267
|
+
record.status_payload()
|
|
268
|
+
for record in self._records.values()
|
|
269
|
+
if record.state not in TERMINAL_STATES
|
|
270
|
+
]
|
|
271
|
+
started = self._started and not self._closed
|
|
272
|
+
return {
|
|
273
|
+
"status": "busy" if active else ("ready" if started else "starting"),
|
|
274
|
+
"uptimeSeconds": int(time.monotonic() - self._started_at),
|
|
275
|
+
"modelLoaded": started,
|
|
276
|
+
"activeExecutions": len(active),
|
|
277
|
+
"maxConcurrency": self.max_concurrent_executions,
|
|
278
|
+
"executions": active,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
def status(self, execution_id: str) -> dict[str, Any]:
|
|
282
|
+
return self._record(execution_id).status_payload()
|
|
283
|
+
|
|
284
|
+
def events(
|
|
285
|
+
self,
|
|
286
|
+
execution_id: str,
|
|
287
|
+
*,
|
|
288
|
+
after_sequence: int = 0,
|
|
289
|
+
wait_seconds: float = 0,
|
|
290
|
+
) -> dict[str, Any]:
|
|
291
|
+
if after_sequence < 0:
|
|
292
|
+
raise ValueError("after_sequence cannot be negative")
|
|
293
|
+
if not 0 <= wait_seconds <= 30:
|
|
294
|
+
raise ValueError("wait_seconds must be in [0, 30]")
|
|
295
|
+
return self._record(execution_id).events_after(
|
|
296
|
+
after_sequence,
|
|
297
|
+
wait_seconds,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def result(self, execution_id: str) -> dict[str, Any]:
|
|
301
|
+
record = self._record(execution_id)
|
|
302
|
+
with record.condition:
|
|
303
|
+
if record.state not in TERMINAL_STATES:
|
|
304
|
+
raise ExecutionNotFinished(execution_id)
|
|
305
|
+
if record.result is None:
|
|
306
|
+
return {
|
|
307
|
+
"executionId": execution_id,
|
|
308
|
+
"state": record.state,
|
|
309
|
+
"error": record.error,
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
"executionId": execution_id,
|
|
313
|
+
"state": record.state,
|
|
314
|
+
"result": record.result.to_dict(),
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
def cancel(self, execution_id: str, reason: str = "cancelled by user") -> dict[str, Any]:
|
|
318
|
+
record = self._record(execution_id)
|
|
319
|
+
with record.condition:
|
|
320
|
+
if record.state in TERMINAL_STATES:
|
|
321
|
+
return {
|
|
322
|
+
"executionId": execution_id,
|
|
323
|
+
"state": record.state,
|
|
324
|
+
"cancelRequested": False,
|
|
325
|
+
}
|
|
326
|
+
assert record.context is not None
|
|
327
|
+
record.context.cancel(reason)
|
|
328
|
+
cancelled_before_start = bool(record.future and record.future.cancel())
|
|
329
|
+
if cancelled_before_start:
|
|
330
|
+
record.set_state("cancelled", error=reason)
|
|
331
|
+
return {
|
|
332
|
+
"executionId": execution_id,
|
|
333
|
+
"state": record.state,
|
|
334
|
+
"cancelRequested": True,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
def close(self) -> None:
|
|
338
|
+
with self._lock:
|
|
339
|
+
if self._closed:
|
|
340
|
+
return
|
|
341
|
+
self._closed = True
|
|
342
|
+
active = [
|
|
343
|
+
record
|
|
344
|
+
for record in self._records.values()
|
|
345
|
+
if record.state not in TERMINAL_STATES
|
|
346
|
+
]
|
|
347
|
+
for record in active:
|
|
348
|
+
assert record.context is not None
|
|
349
|
+
record.context.cancel("service shutdown")
|
|
350
|
+
if record.future and record.future.cancel():
|
|
351
|
+
record.set_state("cancelled", error="service shutdown")
|
|
352
|
+
self._executor.shutdown(wait=True, cancel_futures=True)
|
|
353
|
+
self.runner.close()
|
|
354
|
+
|
|
355
|
+
def _record(self, execution_id: str) -> ExecutionRecord:
|
|
356
|
+
with self._lock:
|
|
357
|
+
record = self._records.get(execution_id)
|
|
358
|
+
if record is None:
|
|
359
|
+
raise ExecutionNotFound(execution_id)
|
|
360
|
+
return record
|
|
361
|
+
|
|
362
|
+
def _run(self, record: ExecutionRecord) -> None:
|
|
363
|
+
record.set_state("running")
|
|
364
|
+
assert record.context is not None
|
|
365
|
+
try:
|
|
366
|
+
result = self._run_with_restarts(record)
|
|
367
|
+
except ExecutionCancelled as exc:
|
|
368
|
+
record.context.mark_unfinished("cancelled", str(exc))
|
|
369
|
+
record.set_state("cancelled", error=str(exc))
|
|
370
|
+
except Exception as exc:
|
|
371
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
372
|
+
record.context.mark_unfinished("failed", error)
|
|
373
|
+
record.set_state("failed", error=error)
|
|
374
|
+
else:
|
|
375
|
+
record.set_state(result.status, result=result)
|
|
376
|
+
|
|
377
|
+
def _run_with_restarts(self, record: ExecutionRecord) -> AlgorithmResult:
|
|
378
|
+
assert record.context is not None
|
|
379
|
+
for attempt in range(1, self.max_attempts + 1):
|
|
380
|
+
with record.condition:
|
|
381
|
+
record.attempt = attempt
|
|
382
|
+
record.condition.notify_all()
|
|
383
|
+
|
|
384
|
+
runner, generation = self._acquire_runner()
|
|
385
|
+
failure: Exception | None = None
|
|
386
|
+
try:
|
|
387
|
+
return runner.run(
|
|
388
|
+
record.request,
|
|
389
|
+
context=record.context,
|
|
390
|
+
mark_unfinished_on_error=False,
|
|
391
|
+
)
|
|
392
|
+
except ExecutionCancelled:
|
|
393
|
+
raise
|
|
394
|
+
except Exception as exc:
|
|
395
|
+
record.context.raise_if_cancelled()
|
|
396
|
+
failure = exc
|
|
397
|
+
if attempt < self.max_attempts:
|
|
398
|
+
delay = random.uniform(
|
|
399
|
+
self.retry_seconds * (1 - self.retry_jitter_ratio),
|
|
400
|
+
self.retry_seconds * (1 + self.retry_jitter_ratio),
|
|
401
|
+
)
|
|
402
|
+
logger.warning(
|
|
403
|
+
"algorithm execution %s failed on attempt %d/%d; "
|
|
404
|
+
"releasing and restarting algorithm in %.3f seconds: %s: %s",
|
|
405
|
+
record.execution_id,
|
|
406
|
+
attempt,
|
|
407
|
+
self.max_attempts,
|
|
408
|
+
delay,
|
|
409
|
+
type(exc).__name__,
|
|
410
|
+
exc,
|
|
411
|
+
)
|
|
412
|
+
else:
|
|
413
|
+
delay = 0.0
|
|
414
|
+
logger.error(
|
|
415
|
+
"algorithm execution %s exhausted %d attempts; "
|
|
416
|
+
"releasing and restarting algorithm for future requests: %s: %s",
|
|
417
|
+
record.execution_id,
|
|
418
|
+
self.max_attempts,
|
|
419
|
+
type(exc).__name__,
|
|
420
|
+
exc,
|
|
421
|
+
)
|
|
422
|
+
finally:
|
|
423
|
+
self._release_runner()
|
|
424
|
+
|
|
425
|
+
self._restart_runner(runner, generation, delay)
|
|
426
|
+
if attempt >= self.max_attempts:
|
|
427
|
+
assert failure is not None
|
|
428
|
+
raise failure
|
|
429
|
+
record.context.raise_if_cancelled()
|
|
430
|
+
|
|
431
|
+
raise AssertionError("retry loop exited unexpectedly")
|
|
432
|
+
|
|
433
|
+
def _acquire_runner(self) -> tuple[AlgorithmRunner, int]:
|
|
434
|
+
with self._runner_condition:
|
|
435
|
+
while self._restart_pending:
|
|
436
|
+
self._runner_condition.wait()
|
|
437
|
+
if self._closed:
|
|
438
|
+
raise ExecutionCancelled("service shutdown")
|
|
439
|
+
self._runner_users += 1
|
|
440
|
+
return self.runner, self._runner_generation
|
|
441
|
+
|
|
442
|
+
def _release_runner(self) -> None:
|
|
443
|
+
with self._runner_condition:
|
|
444
|
+
self._runner_users -= 1
|
|
445
|
+
self._runner_condition.notify_all()
|
|
446
|
+
|
|
447
|
+
def _restart_runner(
|
|
448
|
+
self,
|
|
449
|
+
failed_runner: AlgorithmRunner,
|
|
450
|
+
failed_generation: int,
|
|
451
|
+
delay: float,
|
|
452
|
+
) -> None:
|
|
453
|
+
with self._runner_condition:
|
|
454
|
+
while (
|
|
455
|
+
self._restart_pending
|
|
456
|
+
and failed_generation == self._runner_generation
|
|
457
|
+
):
|
|
458
|
+
self._runner_condition.wait()
|
|
459
|
+
if (
|
|
460
|
+
failed_generation != self._runner_generation
|
|
461
|
+
or failed_runner is not self.runner
|
|
462
|
+
):
|
|
463
|
+
return
|
|
464
|
+
if self._closed:
|
|
465
|
+
raise ExecutionCancelled("service shutdown")
|
|
466
|
+
self._restart_pending = True
|
|
467
|
+
while self._runner_users:
|
|
468
|
+
self._runner_condition.wait()
|
|
469
|
+
|
|
470
|
+
try:
|
|
471
|
+
try:
|
|
472
|
+
failed_runner.close()
|
|
473
|
+
except Exception:
|
|
474
|
+
logger.exception("failed to release algorithm before restart")
|
|
475
|
+
time.sleep(delay)
|
|
476
|
+
replacement = self._runner_factory()
|
|
477
|
+
replacement.start()
|
|
478
|
+
except Exception:
|
|
479
|
+
with self._runner_condition:
|
|
480
|
+
self._restart_pending = False
|
|
481
|
+
self._runner_condition.notify_all()
|
|
482
|
+
raise
|
|
483
|
+
|
|
484
|
+
with self._runner_condition:
|
|
485
|
+
self.runner = replacement
|
|
486
|
+
self._runner_generation += 1
|
|
487
|
+
self._restart_pending = False
|
|
488
|
+
self._runner_condition.notify_all()
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def create_app(
|
|
492
|
+
manager: ExecutionManager,
|
|
493
|
+
*,
|
|
494
|
+
webui: bool = False,
|
|
495
|
+
webui_url: str | None = None,
|
|
496
|
+
token: str | None = None,
|
|
497
|
+
lifecycle: Any | None = None,
|
|
498
|
+
) -> Any:
|
|
499
|
+
"""Create the optional generic HTTP adapter."""
|
|
500
|
+
try:
|
|
501
|
+
from fastapi import FastAPI, Query, Request
|
|
502
|
+
from fastapi.responses import JSONResponse
|
|
503
|
+
except ImportError as exc:
|
|
504
|
+
raise RuntimeError(
|
|
505
|
+
'HTTP service dependencies are missing; install "ls-algorithm-plugin-sdk[service]"'
|
|
506
|
+
) from exc
|
|
507
|
+
|
|
508
|
+
# FastAPI resolves postponed annotations through module globals. Request is
|
|
509
|
+
# imported lazily so the base SDK keeps FastAPI optional; expose it here
|
|
510
|
+
# before route decorators inspect the endpoint signatures.
|
|
511
|
+
globals()["Request"] = Request
|
|
512
|
+
|
|
513
|
+
@asynccontextmanager
|
|
514
|
+
async def lifespan(_: Any):
|
|
515
|
+
manager.start()
|
|
516
|
+
if lifecycle is not None:
|
|
517
|
+
lifecycle.start()
|
|
518
|
+
announce_webui(application)
|
|
519
|
+
try:
|
|
520
|
+
yield
|
|
521
|
+
finally:
|
|
522
|
+
if lifecycle is not None:
|
|
523
|
+
lifecycle.stop()
|
|
524
|
+
manager.close()
|
|
525
|
+
|
|
526
|
+
application = FastAPI(
|
|
527
|
+
title="Algorithm Plugin Service",
|
|
528
|
+
version="0.1.0",
|
|
529
|
+
lifespan=lifespan,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
@application.middleware("http")
|
|
533
|
+
async def authenticate(request: Request, call_next: Any) -> Any:
|
|
534
|
+
public_paths = {"/v1/health", "/v1/metadata"}
|
|
535
|
+
if (
|
|
536
|
+
token
|
|
537
|
+
and request.url.path.startswith("/v1/")
|
|
538
|
+
and request.url.path not in public_paths
|
|
539
|
+
and not hmac.compare_digest(
|
|
540
|
+
request.headers.get("X-LDP-Plugin-Token", ""), token
|
|
541
|
+
)
|
|
542
|
+
):
|
|
543
|
+
return JSONResponse(status_code=401, content={"error": "unauthorized"})
|
|
544
|
+
return await call_next(request)
|
|
545
|
+
|
|
546
|
+
@application.exception_handler(ExecutionNotFound)
|
|
547
|
+
async def handle_not_found(_: Request, exc: ExecutionNotFound) -> JSONResponse:
|
|
548
|
+
return JSONResponse(
|
|
549
|
+
status_code=404,
|
|
550
|
+
content={"error": f"execution not found: {exc.args[0]}"},
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
@application.exception_handler(ExecutionNotFinished)
|
|
554
|
+
async def handle_not_finished(_: Request, exc: ExecutionNotFinished) -> JSONResponse:
|
|
555
|
+
return JSONResponse(
|
|
556
|
+
status_code=409,
|
|
557
|
+
content={"error": f"execution is not finished: {exc.args[0]}"},
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
@application.exception_handler(IdempotencyConflict)
|
|
561
|
+
async def handle_idempotency_conflict(
|
|
562
|
+
_: Request, exc: IdempotencyConflict
|
|
563
|
+
) -> JSONResponse:
|
|
564
|
+
return JSONResponse(
|
|
565
|
+
status_code=409,
|
|
566
|
+
content={"error": f"idempotency key reused with another request: {exc.args[0]}"},
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
@application.get("/v1/metadata")
|
|
570
|
+
def metadata() -> dict[str, Any]:
|
|
571
|
+
return manager.runner.algorithm.metadata().to_dict()
|
|
572
|
+
|
|
573
|
+
@application.get("/v1/health")
|
|
574
|
+
def health() -> dict[str, str]:
|
|
575
|
+
return {"status": "ready"}
|
|
576
|
+
|
|
577
|
+
@application.get("/v1/manifest")
|
|
578
|
+
def manifest() -> dict[str, Any]:
|
|
579
|
+
return manager.manifest()
|
|
580
|
+
|
|
581
|
+
@application.get("/v1/heartbeat")
|
|
582
|
+
def heartbeat() -> dict[str, Any]:
|
|
583
|
+
return manager.heartbeat()
|
|
584
|
+
|
|
585
|
+
@application.post("/v1/executions", status_code=202)
|
|
586
|
+
async def create_execution(request: Request) -> Any:
|
|
587
|
+
try:
|
|
588
|
+
body = await request.json()
|
|
589
|
+
execution_request = AlgorithmRequest.from_dict(body)
|
|
590
|
+
if execution_request.workspace is None:
|
|
591
|
+
raise ValueError("workspace is required")
|
|
592
|
+
except Exception as exc:
|
|
593
|
+
return JSONResponse(
|
|
594
|
+
status_code=422,
|
|
595
|
+
content={"error": f"{type(exc).__name__}: {exc}"},
|
|
596
|
+
)
|
|
597
|
+
return manager.submit(
|
|
598
|
+
execution_request,
|
|
599
|
+
idempotency_key=request.headers.get("Idempotency-Key"),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
@application.get("/v1/executions/{execution_id}")
|
|
603
|
+
def execution_status(execution_id: str) -> dict[str, Any]:
|
|
604
|
+
return manager.status(execution_id)
|
|
605
|
+
|
|
606
|
+
@application.get("/v1/executions/{execution_id}/events")
|
|
607
|
+
def execution_events(
|
|
608
|
+
execution_id: str,
|
|
609
|
+
after_sequence: int = Query(0, alias="afterSequence", ge=0),
|
|
610
|
+
wait_seconds: float = Query(0, alias="waitSeconds", ge=0, le=30),
|
|
611
|
+
) -> dict[str, Any]:
|
|
612
|
+
return manager.events(
|
|
613
|
+
execution_id,
|
|
614
|
+
after_sequence=after_sequence,
|
|
615
|
+
wait_seconds=wait_seconds,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
@application.get("/v1/executions/{execution_id}/result")
|
|
619
|
+
def execution_result(execution_id: str) -> dict[str, Any]:
|
|
620
|
+
return manager.result(execution_id)
|
|
621
|
+
|
|
622
|
+
@application.post("/v1/executions/{execution_id}:cancel", status_code=202)
|
|
623
|
+
async def cancel_execution(execution_id: str, request: Request) -> dict[str, Any]:
|
|
624
|
+
try:
|
|
625
|
+
body = await request.json()
|
|
626
|
+
except Exception:
|
|
627
|
+
body = {}
|
|
628
|
+
return manager.cancel(execution_id, str(body.get("reason", "cancelled by user")))
|
|
629
|
+
|
|
630
|
+
if webui:
|
|
631
|
+
mount_webui(application, webui_url=webui_url)
|
|
632
|
+
|
|
633
|
+
application.state.execution_manager = manager
|
|
634
|
+
return application
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def run_server(
|
|
638
|
+
manager: ExecutionManager,
|
|
639
|
+
*,
|
|
640
|
+
host: str = "0.0.0.0",
|
|
641
|
+
port: int = 8000,
|
|
642
|
+
webui: bool = False,
|
|
643
|
+
token: str | None = None,
|
|
644
|
+
lifecycle: Any | None = None,
|
|
645
|
+
) -> None:
|
|
646
|
+
try:
|
|
647
|
+
import uvicorn
|
|
648
|
+
except ImportError as exc:
|
|
649
|
+
raise RuntimeError(
|
|
650
|
+
'HTTP service dependencies are missing; install "ls-algorithm-plugin-sdk[service]"'
|
|
651
|
+
) from exc
|
|
652
|
+
display_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host
|
|
653
|
+
webui_url = f"http://{display_host}:{port}/ui/" if webui else None
|
|
654
|
+
uvicorn.run(
|
|
655
|
+
create_app(
|
|
656
|
+
manager,
|
|
657
|
+
webui=webui,
|
|
658
|
+
webui_url=webui_url,
|
|
659
|
+
token=token,
|
|
660
|
+
lifecycle=lifecycle,
|
|
661
|
+
),
|
|
662
|
+
host=host,
|
|
663
|
+
port=port,
|
|
664
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Static assets for the optional internal processing UI."""
|