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,52 @@
|
|
|
1
|
+
from .algorithm import Algorithm
|
|
2
|
+
from .context import (
|
|
3
|
+
DatasetProgress,
|
|
4
|
+
EpisodeProgress,
|
|
5
|
+
ExecutionContext,
|
|
6
|
+
ProgressSnapshot
|
|
7
|
+
)
|
|
8
|
+
from .deployment import DeploymentConfig
|
|
9
|
+
from .errors import (
|
|
10
|
+
AlgorithmLoadError,
|
|
11
|
+
AlgorithmPluginError,
|
|
12
|
+
ExecutionCancelled,
|
|
13
|
+
InvalidAlgorithmResult,
|
|
14
|
+
InvalidRequest
|
|
15
|
+
)
|
|
16
|
+
from .loader import load_algorithm
|
|
17
|
+
from .models import (
|
|
18
|
+
AlgorithmInput,
|
|
19
|
+
AlgorithmMetadata,
|
|
20
|
+
AlgorithmRequest,
|
|
21
|
+
AlgorithmResult,
|
|
22
|
+
DatasetResult
|
|
23
|
+
)
|
|
24
|
+
from .registration import PluginRegistrationAgent, RegistrationSettings
|
|
25
|
+
from .release import ReleaseManifest
|
|
26
|
+
from .runner import AlgorithmRunner
|
|
27
|
+
from .service import ExecutionManager
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Algorithm",
|
|
31
|
+
"AlgorithmInput",
|
|
32
|
+
"AlgorithmLoadError",
|
|
33
|
+
"AlgorithmMetadata",
|
|
34
|
+
"AlgorithmPluginError",
|
|
35
|
+
"AlgorithmRequest",
|
|
36
|
+
"AlgorithmResult",
|
|
37
|
+
"AlgorithmRunner",
|
|
38
|
+
"DeploymentConfig",
|
|
39
|
+
"DatasetProgress",
|
|
40
|
+
"DatasetResult",
|
|
41
|
+
"EpisodeProgress",
|
|
42
|
+
"ExecutionCancelled",
|
|
43
|
+
"ExecutionContext",
|
|
44
|
+
"ExecutionManager",
|
|
45
|
+
"InvalidAlgorithmResult",
|
|
46
|
+
"InvalidRequest",
|
|
47
|
+
"PluginRegistrationAgent",
|
|
48
|
+
"ProgressSnapshot",
|
|
49
|
+
"RegistrationSettings",
|
|
50
|
+
"ReleaseManifest",
|
|
51
|
+
"load_algorithm",
|
|
52
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from .context import ExecutionContext
|
|
6
|
+
from .models import AlgorithmMetadata, AlgorithmRequest, AlgorithmResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Algorithm(ABC):
|
|
10
|
+
"""The only runtime contract an algorithm package must implement."""
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def metadata(cls) -> AlgorithmMetadata:
|
|
15
|
+
"""Describe the algorithm without loading models or datasets."""
|
|
16
|
+
|
|
17
|
+
def startup(self) -> None:
|
|
18
|
+
"""Initialize process-level resources before the first execution."""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def execute(
|
|
22
|
+
self,
|
|
23
|
+
request: AlgorithmRequest,
|
|
24
|
+
context: ExecutionContext,
|
|
25
|
+
) -> AlgorithmResult:
|
|
26
|
+
"""Run all datasets and return their final results.
|
|
27
|
+
|
|
28
|
+
Algorithms own dataset/GPU scheduling and optional merging. Concurrent
|
|
29
|
+
workers report live progress through ``context.report_progress()``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def shutdown(self) -> None:
|
|
33
|
+
"""Release process-level resources during service shutdown."""
|
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from contextlib import ExitStack, contextmanager, redirect_stderr, redirect_stdout
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .context import ExecutionContext, ProgressSnapshot
|
|
18
|
+
from .deployment import DEFAULT_CONFIG_NAME, DeploymentConfig, repository_root
|
|
19
|
+
from .errors import ExecutionCancelled
|
|
20
|
+
from .loader import load_algorithm
|
|
21
|
+
from .models import AlgorithmRequest
|
|
22
|
+
from .registration import PluginRegistrationAgent
|
|
23
|
+
from .release import ReleaseManifest
|
|
24
|
+
from .runner import AlgorithmRunner
|
|
25
|
+
from .service import (
|
|
26
|
+
MAX_ATTEMPTS,
|
|
27
|
+
RETRY_JITTER_RATIO,
|
|
28
|
+
RETRY_SECONDS,
|
|
29
|
+
ExecutionManager,
|
|
30
|
+
run_server
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _env_bool(name: str, default: bool = False) -> bool:
|
|
35
|
+
value = os.getenv(name)
|
|
36
|
+
if value is None:
|
|
37
|
+
return default
|
|
38
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _json_object(value: str) -> dict[str, Any]:
|
|
42
|
+
try:
|
|
43
|
+
parsed = json.loads(value)
|
|
44
|
+
except json.JSONDecodeError as exc:
|
|
45
|
+
raise argparse.ArgumentTypeError(f"invalid JSON: {exc}") from exc
|
|
46
|
+
if not isinstance(parsed, dict):
|
|
47
|
+
raise argparse.ArgumentTypeError("value must be a JSON object")
|
|
48
|
+
return parsed
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _gpu_ids(value: str) -> list[int]:
|
|
52
|
+
if not value.strip():
|
|
53
|
+
return []
|
|
54
|
+
try:
|
|
55
|
+
return [int(item.strip()) for item in value.split(",")]
|
|
56
|
+
except ValueError as exc:
|
|
57
|
+
raise argparse.ArgumentTypeError(
|
|
58
|
+
"GPU IDs must be comma-separated integers"
|
|
59
|
+
) from exc
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _environment(value: str) -> tuple[str, str]:
|
|
63
|
+
name, separator, setting = value.partition("=")
|
|
64
|
+
if not separator or not name or not name.replace("_", "").isalnum():
|
|
65
|
+
raise argparse.ArgumentTypeError("environment must use NAME=VALUE")
|
|
66
|
+
if name.startswith("LDP_") or name.startswith("ALGORITHM_"):
|
|
67
|
+
raise argparse.ArgumentTypeError(
|
|
68
|
+
"SDK-managed LDP_/ALGORITHM_ settings must use dedicated options"
|
|
69
|
+
)
|
|
70
|
+
return name, setting
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _add_serve_options(parser: argparse.ArgumentParser) -> None:
|
|
74
|
+
parser.add_argument("algorithm", help="entry-point name or package.module:AlgorithmClass")
|
|
75
|
+
parser.add_argument("--host", default=os.getenv("ALGORITHM_HOST", "0.0.0.0"))
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--port",
|
|
78
|
+
type=int,
|
|
79
|
+
default=int(os.getenv("ALGORITHM_PORT", os.getenv("LDP_PLUGIN_PORT", "9000"))),
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--max-concurrent-executions",
|
|
83
|
+
type=int,
|
|
84
|
+
default=int(
|
|
85
|
+
os.getenv(
|
|
86
|
+
"ALGORITHM_MAX_CONCURRENT_EXECUTIONS",
|
|
87
|
+
os.getenv("LDP_PLUGIN_MAX_CONCURRENCY", "1"),
|
|
88
|
+
)
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument("--scratch-dir", default=os.getenv("ALGORITHM_SCRATCH_DIR"))
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--gpu-ids", type=_gpu_ids, default=None,
|
|
94
|
+
help="GPU IDs injected into every service execution",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--max-attempts",
|
|
98
|
+
type=int,
|
|
99
|
+
default=int(os.getenv("ALGORITHM_MAX_ATTEMPTS", str(MAX_ATTEMPTS))),
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--retry-seconds",
|
|
103
|
+
type=float,
|
|
104
|
+
default=float(os.getenv("ALGORITHM_RETRY_SECONDS", str(RETRY_SECONDS))),
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--retry-jitter-ratio",
|
|
108
|
+
type=float,
|
|
109
|
+
default=float(
|
|
110
|
+
os.getenv("ALGORITHM_RETRY_JITTER_RATIO", str(RETRY_JITTER_RATIO))
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
"--webui",
|
|
115
|
+
action=argparse.BooleanOptionalAction,
|
|
116
|
+
default=_env_bool("ALGORITHM_WEBUI"),
|
|
117
|
+
help="serve the SDK processing UI at /ui/",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
122
|
+
parser = argparse.ArgumentParser(prog="algorithm-plugin")
|
|
123
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
124
|
+
|
|
125
|
+
run_parser = subparsers.add_parser("run", help="run datasets once")
|
|
126
|
+
run_parser.add_argument("algorithm", help="entry-point name or package.module:AlgorithmClass")
|
|
127
|
+
run_parser.add_argument("--input", action="append", type=_json_object, default=[])
|
|
128
|
+
run_parser.add_argument("--merge", action="store_true", default=None)
|
|
129
|
+
run_parser.add_argument("--gpu-ids", type=_gpu_ids)
|
|
130
|
+
run_parser.add_argument("--parameters", type=_json_object)
|
|
131
|
+
run_parser.add_argument("--request-file", type=Path)
|
|
132
|
+
run_parser.add_argument("--scratch-dir")
|
|
133
|
+
run_parser.add_argument("--algorithm-log", type=Path)
|
|
134
|
+
|
|
135
|
+
serve_parser = subparsers.add_parser("serve", help="run the generated HTTP service")
|
|
136
|
+
_add_serve_options(serve_parser)
|
|
137
|
+
|
|
138
|
+
config_parser = subparsers.add_parser(
|
|
139
|
+
"config", help="generate deployment config from the current repository"
|
|
140
|
+
)
|
|
141
|
+
config_parser.add_argument("algorithm", nargs="?", help="normally auto-detected")
|
|
142
|
+
config_parser.add_argument("--output", type=Path, default=Path(DEFAULT_CONFIG_NAME))
|
|
143
|
+
config_parser.add_argument("--repository", type=Path, default=Path.cwd())
|
|
144
|
+
config_parser.add_argument("--host", default="0.0.0.0")
|
|
145
|
+
config_parser.add_argument("--port", type=int, default=9000)
|
|
146
|
+
config_parser.add_argument(
|
|
147
|
+
"--webui", action=argparse.BooleanOptionalAction, default=True
|
|
148
|
+
)
|
|
149
|
+
config_parser.add_argument("--max-concurrency", type=int, default=1)
|
|
150
|
+
config_parser.add_argument("--scratch-dir")
|
|
151
|
+
config_parser.add_argument(
|
|
152
|
+
"--gpu-ids", type=_gpu_ids, default=[],
|
|
153
|
+
help="GPU IDs injected into every service execution",
|
|
154
|
+
)
|
|
155
|
+
config_parser.add_argument("--service-token")
|
|
156
|
+
config_parser.add_argument("--compute-url", default=os.getenv("LDP_COMPUTE_URL"))
|
|
157
|
+
config_parser.add_argument("--api-key", default=os.getenv("LDP_INTERNAL_API_KEY"))
|
|
158
|
+
config_parser.add_argument("--public-url", default=os.getenv("LDP_PLUGIN_PUBLIC_URL"))
|
|
159
|
+
config_parser.add_argument("--input-root", default=os.getenv("LDP_JOB_INPUT_ROOT"))
|
|
160
|
+
config_parser.add_argument(
|
|
161
|
+
"--workspace-root", default=os.getenv("LDP_JOB_WORKSPACE_ROOT")
|
|
162
|
+
)
|
|
163
|
+
config_parser.add_argument("--instance-key", default=os.getenv("LDP_PLUGIN_INSTANCE_KEY"))
|
|
164
|
+
config_parser.add_argument("--cluster", default=os.getenv("LDP_CLUSTER"))
|
|
165
|
+
config_parser.add_argument("--namespace", default=os.getenv("POD_NAMESPACE"))
|
|
166
|
+
config_parser.add_argument("--node-name", default=os.getenv("NODE_NAME"))
|
|
167
|
+
config_parser.add_argument(
|
|
168
|
+
"--local",
|
|
169
|
+
action="store_true",
|
|
170
|
+
help="generate a local config without compute registration",
|
|
171
|
+
)
|
|
172
|
+
config_parser.add_argument(
|
|
173
|
+
"--env", action="append", type=_environment, default=[], metavar="NAME=VALUE"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
start_parser = subparsers.add_parser("start", help="start the configured service")
|
|
177
|
+
start_parser.add_argument("--config", type=Path, default=Path(DEFAULT_CONFIG_NAME))
|
|
178
|
+
start_parser.add_argument(
|
|
179
|
+
"--foreground", action="store_true", help="stay in foreground for systemd"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
stop_parser = subparsers.add_parser("stop", help="stop the configured service")
|
|
183
|
+
stop_parser.add_argument("--config", type=Path, default=Path(DEFAULT_CONFIG_NAME))
|
|
184
|
+
stop_parser.add_argument("--timeout", type=float, default=30.0)
|
|
185
|
+
return parser
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _request_from_args(args: argparse.Namespace) -> AlgorithmRequest:
|
|
189
|
+
values: dict[str, Any] = {}
|
|
190
|
+
if args.request_file is not None:
|
|
191
|
+
try:
|
|
192
|
+
values = json.loads(args.request_file.read_text(encoding="utf-8"))
|
|
193
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
194
|
+
raise ValueError(f"cannot read request file: {exc}") from exc
|
|
195
|
+
if not isinstance(values, dict):
|
|
196
|
+
raise ValueError("request file must contain a JSON object")
|
|
197
|
+
if args.input:
|
|
198
|
+
values["input"] = args.input
|
|
199
|
+
if args.merge is not None:
|
|
200
|
+
values["merge"] = args.merge
|
|
201
|
+
if args.gpu_ids is not None:
|
|
202
|
+
values["gpuIds"] = args.gpu_ids
|
|
203
|
+
if args.parameters is not None:
|
|
204
|
+
values["parameters"] = args.parameters
|
|
205
|
+
request = AlgorithmRequest.from_dict(values)
|
|
206
|
+
if args.gpu_ids is not None:
|
|
207
|
+
request = replace(request, gpu_ids=args.gpu_ids)
|
|
208
|
+
return request
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class TerminalProgress:
|
|
212
|
+
def __init__(self, stream: Any = None) -> None:
|
|
213
|
+
self.stream = stream or sys.stderr
|
|
214
|
+
self.enabled = bool(getattr(self.stream, "isatty", lambda: False)())
|
|
215
|
+
self._line_count = 0
|
|
216
|
+
self._lock = threading.Lock()
|
|
217
|
+
|
|
218
|
+
def update(self, snapshot: ProgressSnapshot) -> None:
|
|
219
|
+
if not self.enabled:
|
|
220
|
+
return
|
|
221
|
+
lines = []
|
|
222
|
+
for dataset, progress in snapshot.datasets.items():
|
|
223
|
+
lines.append(self._format_line(dataset, progress))
|
|
224
|
+
lines.extend(
|
|
225
|
+
self._format_line(f" {episode}", episode_progress)
|
|
226
|
+
for episode, episode_progress in progress.episodes.items()
|
|
227
|
+
)
|
|
228
|
+
with self._lock:
|
|
229
|
+
if self._line_count:
|
|
230
|
+
self.stream.write(f"\x1b[{self._line_count}F")
|
|
231
|
+
for line in lines:
|
|
232
|
+
self.stream.write(f"\x1b[2K{line}\n")
|
|
233
|
+
self.stream.flush()
|
|
234
|
+
self._line_count = len(lines)
|
|
235
|
+
|
|
236
|
+
def close(self) -> None:
|
|
237
|
+
if self.enabled:
|
|
238
|
+
self.stream.flush()
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _format_line(dataset: str, progress: Any) -> str:
|
|
242
|
+
name = dataset.rstrip("/") or dataset
|
|
243
|
+
percent = " --%" if progress.percent is None else f"{progress.percent:5.1f}%"
|
|
244
|
+
filled = 0 if progress.percent is None else round(progress.percent / 5)
|
|
245
|
+
bar = f"[{'#' * filled}{'-' * (20 - filled)}]"
|
|
246
|
+
message = f" {progress.message}" if progress.message else ""
|
|
247
|
+
return f"{name:24.24} {bar} {percent} {progress.stage:16} {progress.status:9}{message}"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@contextmanager
|
|
251
|
+
def _cancel_on_interrupt(context: ExecutionContext, stream: Any):
|
|
252
|
+
interrupted = False
|
|
253
|
+
previous_handler = signal.getsignal(signal.SIGINT)
|
|
254
|
+
|
|
255
|
+
def handle_interrupt(_signum: int, _frame: Any) -> None:
|
|
256
|
+
nonlocal interrupted
|
|
257
|
+
if interrupted:
|
|
258
|
+
raise KeyboardInterrupt
|
|
259
|
+
interrupted = True
|
|
260
|
+
context.cancel("interrupted by user")
|
|
261
|
+
stream.write("\nCancellation requested; waiting for the algorithm to stop.\n")
|
|
262
|
+
stream.flush()
|
|
263
|
+
|
|
264
|
+
signal.signal(signal.SIGINT, handle_interrupt)
|
|
265
|
+
try:
|
|
266
|
+
yield
|
|
267
|
+
finally:
|
|
268
|
+
signal.signal(signal.SIGINT, previous_handler)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _run(args: argparse.Namespace) -> int:
|
|
272
|
+
request = _request_from_args(args)
|
|
273
|
+
progress = TerminalProgress()
|
|
274
|
+
context = ExecutionContext(
|
|
275
|
+
f"cli-{id(args)}",
|
|
276
|
+
[item.dataset for item in request.inputs],
|
|
277
|
+
scratch_dir=args.scratch_dir,
|
|
278
|
+
progress_callback=progress.update,
|
|
279
|
+
)
|
|
280
|
+
try:
|
|
281
|
+
with ExitStack() as stack:
|
|
282
|
+
if args.algorithm_log is not None:
|
|
283
|
+
args.algorithm_log.parent.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
stream = stack.enter_context(
|
|
285
|
+
args.algorithm_log.open("w", encoding="utf-8", buffering=1)
|
|
286
|
+
)
|
|
287
|
+
stack.enter_context(redirect_stdout(stream))
|
|
288
|
+
stack.enter_context(redirect_stderr(stream))
|
|
289
|
+
runner = AlgorithmRunner(load_algorithm(args.algorithm))
|
|
290
|
+
try:
|
|
291
|
+
with _cancel_on_interrupt(context, progress.stream):
|
|
292
|
+
result = runner.run(request, context=context)
|
|
293
|
+
except ExecutionCancelled as exc:
|
|
294
|
+
print(f"CANCELLED: {exc}", file=progress.stream)
|
|
295
|
+
return 130
|
|
296
|
+
finally:
|
|
297
|
+
runner.close()
|
|
298
|
+
finally:
|
|
299
|
+
progress.close()
|
|
300
|
+
print(json.dumps({"type": "result", **result.to_dict()}, ensure_ascii=False))
|
|
301
|
+
return 0 if result.status == "succeeded" else 1
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _serve(args: argparse.Namespace) -> int:
|
|
305
|
+
algorithm = load_algorithm(args.algorithm)
|
|
306
|
+
|
|
307
|
+
def create_runner() -> AlgorithmRunner:
|
|
308
|
+
return AlgorithmRunner(load_algorithm(args.algorithm))
|
|
309
|
+
|
|
310
|
+
release = ReleaseManifest.discover(algorithm, start=Path.cwd(), required=False)
|
|
311
|
+
manager = ExecutionManager(
|
|
312
|
+
AlgorithmRunner(algorithm),
|
|
313
|
+
max_concurrent_executions=args.max_concurrent_executions,
|
|
314
|
+
scratch_dir=args.scratch_dir,
|
|
315
|
+
gpu_ids=args.gpu_ids,
|
|
316
|
+
runner_factory=create_runner,
|
|
317
|
+
max_attempts=args.max_attempts,
|
|
318
|
+
retry_seconds=args.retry_seconds,
|
|
319
|
+
retry_jitter_ratio=args.retry_jitter_ratio,
|
|
320
|
+
release_manifest=release,
|
|
321
|
+
)
|
|
322
|
+
registration = PluginRegistrationAgent.from_env(manager.manifest, manager.heartbeat)
|
|
323
|
+
if registration is not None and release is None:
|
|
324
|
+
raise RuntimeError(
|
|
325
|
+
"compute registration requires release-manifest.json in the Algorithm repository"
|
|
326
|
+
)
|
|
327
|
+
run_server(
|
|
328
|
+
manager,
|
|
329
|
+
host=args.host,
|
|
330
|
+
port=args.port,
|
|
331
|
+
webui=args.webui,
|
|
332
|
+
token=os.getenv("LDP_PLUGIN_TOKEN") or None,
|
|
333
|
+
lifecycle=registration,
|
|
334
|
+
)
|
|
335
|
+
return 0
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _config(args: argparse.Namespace) -> int:
|
|
339
|
+
registration = None
|
|
340
|
+
if not args.local:
|
|
341
|
+
supplied = {
|
|
342
|
+
"computeUrl": args.compute_url,
|
|
343
|
+
"apiKey": args.api_key,
|
|
344
|
+
"publicUrl": args.public_url,
|
|
345
|
+
"inputRoot": args.input_root,
|
|
346
|
+
"workspaceRoot": args.workspace_root,
|
|
347
|
+
"cluster": args.cluster,
|
|
348
|
+
"namespace": args.namespace,
|
|
349
|
+
"nodeName": args.node_name,
|
|
350
|
+
}
|
|
351
|
+
missing = [name for name, value in supplied.items() if not str(value or "").strip()]
|
|
352
|
+
if missing:
|
|
353
|
+
raise ValueError(
|
|
354
|
+
"deployment registration requires "
|
|
355
|
+
+ ", ".join(missing)
|
|
356
|
+
+ "; use --local to disable registration"
|
|
357
|
+
)
|
|
358
|
+
registration = {name: str(value) for name, value in supplied.items()}
|
|
359
|
+
if args.instance_key:
|
|
360
|
+
registration["instanceKey"] = args.instance_key
|
|
361
|
+
root = repository_root(args.repository)
|
|
362
|
+
output = args.output if args.output.is_absolute() else root / args.output
|
|
363
|
+
config = DeploymentConfig.generate(
|
|
364
|
+
output=output,
|
|
365
|
+
root=root,
|
|
366
|
+
algorithm=args.algorithm,
|
|
367
|
+
host=args.host,
|
|
368
|
+
port=args.port,
|
|
369
|
+
webui=args.webui,
|
|
370
|
+
max_concurrency=args.max_concurrency,
|
|
371
|
+
scratch_dir=args.scratch_dir,
|
|
372
|
+
token=args.service_token,
|
|
373
|
+
gpu_ids=args.gpu_ids,
|
|
374
|
+
registration=registration,
|
|
375
|
+
environment=dict(args.env),
|
|
376
|
+
)
|
|
377
|
+
config.save()
|
|
378
|
+
identity = config.payload["algorithm"]
|
|
379
|
+
print(
|
|
380
|
+
f"Generated {config.path} for {identity['type']}@{identity['version']} "
|
|
381
|
+
f"({identity['implementationDigest']})"
|
|
382
|
+
)
|
|
383
|
+
return 0
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _proc_start_time(pid: int) -> str | None:
|
|
387
|
+
try:
|
|
388
|
+
fields = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8").split()
|
|
389
|
+
except OSError:
|
|
390
|
+
return None
|
|
391
|
+
return fields[21] if len(fields) > 21 else None
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _read_process_state(config: DeploymentConfig) -> dict[str, Any] | None:
|
|
395
|
+
try:
|
|
396
|
+
state = json.loads(config.pid_file.read_text(encoding="utf-8"))
|
|
397
|
+
except FileNotFoundError:
|
|
398
|
+
return None
|
|
399
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
400
|
+
raise RuntimeError(f"cannot read PID state {config.pid_file}: {exc}") from exc
|
|
401
|
+
if not isinstance(state, dict) or not isinstance(state.get("pid"), int):
|
|
402
|
+
raise RuntimeError(f"invalid PID state: {config.pid_file}")
|
|
403
|
+
return state
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _is_managed_process(config: DeploymentConfig, state: dict[str, Any]) -> bool:
|
|
407
|
+
pid = int(state["pid"])
|
|
408
|
+
if _proc_start_time(pid) != str(state.get("startTime")):
|
|
409
|
+
return False
|
|
410
|
+
try:
|
|
411
|
+
command = Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ").decode()
|
|
412
|
+
except OSError:
|
|
413
|
+
return False
|
|
414
|
+
return (
|
|
415
|
+
"algorithm_plugin_sdk.cli" in command
|
|
416
|
+
and " serve " in f" {command} "
|
|
417
|
+
and config.algorithm_reference in command
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _start(args: argparse.Namespace) -> int:
|
|
422
|
+
config = DeploymentConfig.load(args.config)
|
|
423
|
+
config.save() # Persist any auto-refreshed release identity.
|
|
424
|
+
service = config.payload["service"]
|
|
425
|
+
command = [
|
|
426
|
+
sys.executable,
|
|
427
|
+
"-m",
|
|
428
|
+
"algorithm_plugin_sdk.cli",
|
|
429
|
+
"serve",
|
|
430
|
+
config.algorithm_reference,
|
|
431
|
+
"--host",
|
|
432
|
+
str(service["host"]),
|
|
433
|
+
"--port",
|
|
434
|
+
str(service["port"]),
|
|
435
|
+
"--max-concurrent-executions",
|
|
436
|
+
str(service["maxConcurrency"]),
|
|
437
|
+
]
|
|
438
|
+
command.append("--webui" if service.get("webui") else "--no-webui")
|
|
439
|
+
if service.get("scratchDir"):
|
|
440
|
+
command.extend(("--scratch-dir", str(service["scratchDir"])))
|
|
441
|
+
if "gpuIds" in service:
|
|
442
|
+
command.extend(("--gpu-ids", ",".join(str(value) for value in service["gpuIds"])))
|
|
443
|
+
|
|
444
|
+
if args.foreground:
|
|
445
|
+
os.chdir(config.repository)
|
|
446
|
+
os.execve(sys.executable, command, config.process_environment())
|
|
447
|
+
|
|
448
|
+
existing = _read_process_state(config)
|
|
449
|
+
if existing and _is_managed_process(config, existing):
|
|
450
|
+
print(f"Algorithm service is already running (pid {existing['pid']})")
|
|
451
|
+
return 0
|
|
452
|
+
if existing:
|
|
453
|
+
config.pid_file.unlink(missing_ok=True)
|
|
454
|
+
|
|
455
|
+
config.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
456
|
+
with config.log_file.open("ab", buffering=0) as log:
|
|
457
|
+
process = subprocess.Popen(
|
|
458
|
+
command,
|
|
459
|
+
cwd=config.repository,
|
|
460
|
+
env=config.process_environment(),
|
|
461
|
+
stdin=subprocess.DEVNULL,
|
|
462
|
+
stdout=log,
|
|
463
|
+
stderr=subprocess.STDOUT,
|
|
464
|
+
start_new_session=True,
|
|
465
|
+
)
|
|
466
|
+
time.sleep(0.25)
|
|
467
|
+
status = process.poll()
|
|
468
|
+
if status is not None:
|
|
469
|
+
raise RuntimeError(
|
|
470
|
+
f"algorithm service exited with status {status}; see {config.log_file}"
|
|
471
|
+
)
|
|
472
|
+
start_time = _proc_start_time(process.pid)
|
|
473
|
+
if start_time is None:
|
|
474
|
+
raise RuntimeError("algorithm service disappeared during startup")
|
|
475
|
+
config.pid_file.write_text(
|
|
476
|
+
json.dumps(
|
|
477
|
+
{
|
|
478
|
+
"pid": process.pid,
|
|
479
|
+
"startTime": start_time,
|
|
480
|
+
"algorithm": config.algorithm_reference,
|
|
481
|
+
"config": str(config.path),
|
|
482
|
+
},
|
|
483
|
+
indent=2,
|
|
484
|
+
)
|
|
485
|
+
+ "\n",
|
|
486
|
+
encoding="utf-8",
|
|
487
|
+
)
|
|
488
|
+
print(f"Started algorithm service (pid {process.pid}); log: {config.log_file}")
|
|
489
|
+
return 0
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _stop(args: argparse.Namespace) -> int:
|
|
493
|
+
if args.timeout < 0:
|
|
494
|
+
raise ValueError("timeout cannot be negative")
|
|
495
|
+
config = DeploymentConfig.load(args.config, refresh=False)
|
|
496
|
+
state = _read_process_state(config)
|
|
497
|
+
if state is None:
|
|
498
|
+
print("Algorithm service is not running")
|
|
499
|
+
return 0
|
|
500
|
+
if not _is_managed_process(config, state):
|
|
501
|
+
config.pid_file.unlink(missing_ok=True)
|
|
502
|
+
print("Algorithm service is not running; removed stale PID state")
|
|
503
|
+
return 0
|
|
504
|
+
pid = int(state["pid"])
|
|
505
|
+
os.kill(pid, signal.SIGTERM)
|
|
506
|
+
deadline = time.monotonic() + args.timeout
|
|
507
|
+
while _is_managed_process(config, state) and time.monotonic() < deadline:
|
|
508
|
+
time.sleep(0.1)
|
|
509
|
+
if _is_managed_process(config, state):
|
|
510
|
+
os.kill(pid, signal.SIGKILL)
|
|
511
|
+
while _is_managed_process(config, state):
|
|
512
|
+
time.sleep(0.05)
|
|
513
|
+
config.pid_file.unlink(missing_ok=True)
|
|
514
|
+
print(f"Stopped algorithm service (pid {pid})")
|
|
515
|
+
return 0
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def main(argv: list[str] | None = None) -> int:
|
|
519
|
+
logging.basicConfig(
|
|
520
|
+
level=logging.INFO,
|
|
521
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
522
|
+
)
|
|
523
|
+
parser = build_parser()
|
|
524
|
+
args = parser.parse_args(argv)
|
|
525
|
+
handlers = {
|
|
526
|
+
"run": _run,
|
|
527
|
+
"serve": _serve,
|
|
528
|
+
"config": _config,
|
|
529
|
+
"start": _start,
|
|
530
|
+
"stop": _stop,
|
|
531
|
+
}
|
|
532
|
+
try:
|
|
533
|
+
return handlers[args.command](args)
|
|
534
|
+
except KeyboardInterrupt:
|
|
535
|
+
return 130
|
|
536
|
+
except Exception as exc:
|
|
537
|
+
print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
538
|
+
return 2
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
if __name__ == "__main__":
|
|
542
|
+
raise SystemExit(main())
|