ls-algorithm-plugin-sdk 0.3.1__tar.gz → 0.3.3__tar.gz

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.
Files changed (47) hide show
  1. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/PKG-INFO +1 -1
  2. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/pyproject.toml +1 -1
  3. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/cli_impl/configure.py +32 -5
  4. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/cli_impl/run.py +5 -0
  5. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/cli_impl/serve.py +16 -2
  6. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/deployment.py +12 -0
  7. ls_algorithm_plugin_sdk-0.3.3/src/algorithm_plugin_sdk/gpu_isolation.py +27 -0
  8. ls_algorithm_plugin_sdk-0.3.3/src/algorithm_plugin_sdk/log_manager.py +76 -0
  9. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/service.py +80 -2
  10. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/ls_algorithm_plugin_sdk.egg-info/PKG-INFO +1 -1
  11. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/ls_algorithm_plugin_sdk.egg-info/SOURCES.txt +3 -0
  12. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_configure.py +34 -2
  13. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_deployment.py +5 -0
  14. ls_algorithm_plugin_sdk-0.3.3/tests/test_gpu_isolation.py +38 -0
  15. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_runner.py +26 -0
  16. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_service.py +20 -0
  17. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/README.md +0 -0
  18. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/setup.cfg +0 -0
  19. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/__init__.py +0 -0
  20. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/algorithm.py +0 -0
  21. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/cli.py +0 -0
  22. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/cli_impl/__init__.py +0 -0
  23. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/cli_impl/parsing.py +0 -0
  24. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/context.py +0 -0
  25. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/errors.py +0 -0
  26. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/examples/__init__.py +0 -0
  27. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/examples/example_algorithm.py +0 -0
  28. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/examples/simulated_algorithm.py +0 -0
  29. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/loader.py +0 -0
  30. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/models.py +0 -0
  31. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/registration.py +0 -0
  32. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/release.py +0 -0
  33. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/runner.py +0 -0
  34. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/webui/__init__.py +0 -0
  35. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/webui/app.css +0 -0
  36. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/webui/app.js +0 -0
  37. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/webui/index.html +0 -0
  38. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/webui_app.py +0 -0
  39. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/ls_algorithm_plugin_sdk.egg-info/dependency_links.txt +0 -0
  40. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/ls_algorithm_plugin_sdk.egg-info/entry_points.txt +0 -0
  41. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/ls_algorithm_plugin_sdk.egg-info/requires.txt +0 -0
  42. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/src/ls_algorithm_plugin_sdk.egg-info/top_level.txt +0 -0
  43. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_cli.py +0 -0
  44. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_models.py +0 -0
  45. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_registration.py +0 -0
  46. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_release.py +0 -0
  47. {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_sdk_automation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ls-algorithm-plugin-sdk
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Protocol-independent runtime SDK for dataset algorithms
5
5
  Author: Ling Robotics
6
6
  License: Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ls-algorithm-plugin-sdk"
7
- version = "0.3.1"
7
+ version = "0.3.3"
8
8
  description = "Protocol-independent runtime SDK for dataset algorithms"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -16,7 +16,7 @@ from ..deployment import (
16
16
  repository_root,
17
17
  stable_instance_key,
18
18
  )
19
- from .parsing import environment, gpu_ids
19
+ from .parsing import environment, gpu_ids, json_object
20
20
 
21
21
 
22
22
  SYSTEMD_UNIT_DIR = Path("/etc/systemd/system")
@@ -56,7 +56,11 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
56
56
  action=argparse.BooleanOptionalAction,
57
57
  default=True,
58
58
  )
59
- parser.add_argument("--max-concurrency", type=int, default=1)
59
+ parser.add_argument(
60
+ "--max-concurrency",
61
+ type=int,
62
+ help="maximum concurrent executions (default: GPU count, or 1)",
63
+ )
60
64
  parser.add_argument("--scratch-dir")
61
65
  parser.add_argument(
62
66
  "--gpu-ids",
@@ -64,6 +68,12 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
64
68
  default=[],
65
69
  help="GPU IDs injected into every service execution",
66
70
  )
71
+ parser.add_argument(
72
+ "--default-parameters",
73
+ type=json_object,
74
+ default={},
75
+ help="JSON object used to fill missing request parameters",
76
+ )
67
77
  parser.add_argument("--service-token")
68
78
  parser.add_argument(
69
79
  "--compute-url",
@@ -139,10 +149,18 @@ def render_systemd_unit(
139
149
  repository: Path,
140
150
  config_path: Path,
141
151
  service_name: str,
152
+ gpu_ids: list[int] | None = None,
142
153
  ) -> str:
143
154
  root = repository.resolve()
155
+ working_directory = root / "jobs"
144
156
  launcher = root / ".venv" / "bin" / "algorithm-plugin"
145
157
  config = config_path.resolve()
158
+ environment = ["Environment=PYTHONUNBUFFERED=1"]
159
+ if gpu_ids:
160
+ environment.append(
161
+ "Environment=CUDA_VISIBLE_DEVICES="
162
+ + ",".join(str(gpu_id) for gpu_id in gpu_ids)
163
+ )
146
164
  return "\n".join(
147
165
  (
148
166
  "[Unit]",
@@ -153,12 +171,13 @@ def render_systemd_unit(
153
171
  "[Service]",
154
172
  "Type=simple",
155
173
  "User=root",
156
- f"WorkingDirectory={_escape_systemd_path(root)}",
174
+ f"WorkingDirectory={_escape_systemd_path(working_directory)}",
157
175
  (
158
176
  f"ExecStart={_quote_systemd_path(launcher)} "
159
177
  f"serve --config {_quote_systemd_path(config)}"
160
178
  ),
161
- "Environment=PYTHONUNBUFFERED=1",
179
+ *environment,
180
+ "LimitNOFILE=524288",
162
181
  "Restart=on-failure",
163
182
  "RestartSec=5s",
164
183
  "",
@@ -376,6 +395,11 @@ def generate_config(
376
395
  args: argparse.Namespace,
377
396
  repository: Path,
378
397
  ) -> DeploymentConfig:
398
+ max_concurrency = (
399
+ args.max_concurrency
400
+ if args.max_concurrency is not None
401
+ else max(len(args.gpu_ids), 1)
402
+ )
379
403
  config = DeploymentConfig.generate(
380
404
  output=args.config_output,
381
405
  root=repository,
@@ -383,12 +407,13 @@ def generate_config(
383
407
  host=args.host,
384
408
  port=args.port,
385
409
  webui=args.webui,
386
- max_concurrency=args.max_concurrency,
410
+ max_concurrency=max_concurrency,
387
411
  scratch_dir=args.scratch_dir,
388
412
  token=args.service_token,
389
413
  registration=registration_from_args(args, repository),
390
414
  gpu_ids=args.gpu_ids,
391
415
  environment=dict(args.env),
416
+ default_parameters=args.default_parameters,
392
417
  )
393
418
  config.save()
394
419
  return config
@@ -407,6 +432,7 @@ def execute(args: argparse.Namespace) -> int:
407
432
  repository=repository,
408
433
  config_path=config_path,
409
434
  service_name=service_name,
435
+ gpu_ids=args.gpu_ids,
410
436
  )
411
437
 
412
438
  inspect_systemd_unit(
@@ -414,6 +440,7 @@ def execute(args: argparse.Namespace) -> int:
414
440
  content=unit,
415
441
  force=args.force,
416
442
  )
443
+ (repository / "jobs").mkdir(exist_ok=True)
417
444
  config = generate_config(args, repository)
418
445
  unit_path, changed = write_systemd_unit(
419
446
  service_name=service_name,
@@ -17,6 +17,7 @@ from typing import Any, Iterator
17
17
 
18
18
  from ..context import ExecutionContext, ProgressSnapshot
19
19
  from ..errors import ExecutionCancelled
20
+ from ..gpu_isolation import apply_gpu_isolation
20
21
  from ..loader import load_algorithm
21
22
  from ..models import AlgorithmRequest
22
23
  from ..runner import AlgorithmRunner
@@ -125,6 +126,10 @@ def cancel_on_interrupt(
125
126
 
126
127
  def execute(args: argparse.Namespace) -> int:
127
128
  request = request_from_args(args)
129
+ request = replace(
130
+ request,
131
+ gpu_ids=apply_gpu_isolation(request.gpu_ids),
132
+ )
128
133
  progress = TerminalProgress()
129
134
  context = ExecutionContext(
130
135
  f"cli-{id(args)}",
@@ -5,6 +5,7 @@ import os
5
5
  from pathlib import Path
6
6
 
7
7
  from ..deployment import DEFAULT_CONFIG_NAME, DeploymentConfig
8
+ from ..gpu_isolation import apply_gpu_isolation
8
9
  from ..loader import load_algorithm
9
10
  from ..registration import PluginRegistrationAgent
10
11
  from ..release import ReleaseManifest
@@ -78,9 +79,12 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
78
79
 
79
80
 
80
81
  def execute(args: argparse.Namespace) -> int:
82
+ config: DeploymentConfig | None = None
83
+ service: dict[str, object] | None = None
81
84
  if args.config:
82
- config = DeploymentConfig.load(args.config)
83
- config.save()
85
+ # Apply the process mask before refreshing/loading the algorithm. A
86
+ # repository module may import CUDA during module import.
87
+ config = DeploymentConfig.load(args.config, refresh=False)
84
88
  service = config.payload["service"]
85
89
  args.algorithm = config.algorithm_reference
86
90
  args.host = str(service["host"])
@@ -96,6 +100,11 @@ def execute(args: argparse.Namespace) -> int:
96
100
  elif not args.algorithm:
97
101
  raise ValueError("algorithm is required unless --config is provided")
98
102
 
103
+ args.gpu_ids = apply_gpu_isolation(args.gpu_ids)
104
+ if config is not None:
105
+ config.refresh()
106
+ config.save()
107
+
99
108
  algorithm_reference = str(args.algorithm)
100
109
  algorithm = load_algorithm(algorithm_reference)
101
110
 
@@ -105,6 +114,10 @@ def execute(args: argparse.Namespace) -> int:
105
114
  release = ReleaseManifest.discover(algorithm, start=Path.cwd(), required=False)
106
115
  manager = ExecutionManager(
107
116
  AlgorithmRunner(algorithm),
117
+ default_parameters=(
118
+ service.get("defaultParameters", {})
119
+ if service is not None else None
120
+ ),
108
121
  max_concurrent_executions=args.max_concurrent_executions,
109
122
  scratch_dir=args.scratch_dir,
110
123
  gpu_ids=args.gpu_ids,
@@ -113,6 +126,7 @@ def execute(args: argparse.Namespace) -> int:
113
126
  retry_seconds=args.retry_seconds,
114
127
  retry_jitter_ratio=args.retry_jitter_ratio,
115
128
  release_manifest=release,
129
+ repository=(config.repository if config is not None else Path.cwd()),
116
130
  )
117
131
  registration = PluginRegistrationAgent.from_env(manager.manifest, manager.heartbeat)
118
132
  if registration is not None and release is None:
@@ -145,6 +145,7 @@ class DeploymentConfig:
145
145
  registration: dict[str, str] | None,
146
146
  gpu_ids: list[int] | None = None,
147
147
  environment: dict[str, str] | None = None,
148
+ default_parameters: dict[str, Any] | None = None,
148
149
  ) -> "DeploymentConfig":
149
150
  repository = repository_root(root)
150
151
  _make_repository_importable(repository)
@@ -190,6 +191,7 @@ class DeploymentConfig:
190
191
  "scratchDir": _absolute(scratch_dir, repository),
191
192
  "gpuIds": configured_gpu_ids,
192
193
  "token": token or None,
194
+ "defaultParameters": dict(default_parameters or {}),
193
195
  },
194
196
  "registration": registration,
195
197
  "environment": dict(sorted((environment or {}).items())),
@@ -273,6 +275,16 @@ class DeploymentConfig:
273
275
  ) or len(gpu_ids) != len(set(gpu_ids)):
274
276
  raise RuntimeError("service gpuIds must be unique non-negative integers")
275
277
 
278
+ default_parameters = service.get("defaultParameters", {})
279
+ if not isinstance(default_parameters, dict):
280
+ raise RuntimeError("defaultParameters must be an object")
281
+ if any(not isinstance(key, str) for key in default_parameters):
282
+ raise RuntimeError("defaultParameters keys must be strings")
283
+ try:
284
+ json.dumps(default_parameters)
285
+ except (TypeError, ValueError) as exc:
286
+ raise RuntimeError("defaultParameters must be JSON serializable") from exc
287
+
276
288
  def _validate_registration(self) -> None:
277
289
  registration = self.payload.get("registration")
278
290
  if registration is None:
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Sequence
5
+
6
+
7
+ def apply_gpu_isolation(gpu_ids: Sequence[int] | None) -> list[int]:
8
+ """Mask physical GPUs for this process and return local GPU ordinals."""
9
+ if gpu_ids is None:
10
+ return []
11
+
12
+ physical_gpu_ids = list(gpu_ids)
13
+ if any(
14
+ isinstance(gpu_id, bool)
15
+ or not isinstance(gpu_id, int)
16
+ or gpu_id < 0
17
+ for gpu_id in physical_gpu_ids
18
+ ) or len(physical_gpu_ids) != len(set(physical_gpu_ids)):
19
+ raise ValueError("gpu_ids must be unique non-negative integers")
20
+
21
+ if not physical_gpu_ids:
22
+ return []
23
+
24
+ os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(
25
+ str(gpu_id) for gpu_id in physical_gpu_ids
26
+ )
27
+ return list(range(len(physical_gpu_ids)))
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ import threading
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .context import utc_now
11
+
12
+ _SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
13
+
14
+
15
+ def _safe_name(value: str) -> str:
16
+ cleaned = _SAFE_NAME.sub("_", str(value).strip()).strip("._")
17
+ return cleaned or "job"
18
+
19
+
20
+ class ExecutionLog:
21
+ """Per-execution, line-buffered log sink attached to context.logger."""
22
+
23
+ def __init__(self, *, log_dir: str | Path, job_id: str, execution_id: str, logger: logging.Logger) -> None:
24
+ directory = Path(log_dir).expanduser()
25
+ directory.mkdir(parents=True, exist_ok=True)
26
+ path = directory / f"{_safe_name(job_id)}.log"
27
+ if path.exists():
28
+ path = directory / f"{_safe_name(job_id)}-{_safe_name(execution_id)}.log"
29
+ self.path = path
30
+ self._stream = path.open("a", encoding="utf-8", buffering=1)
31
+ self._lock = threading.RLock()
32
+ self._handler = _FlushFileHandler(self._stream)
33
+ self._handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
34
+ self._handler.setLevel(logging.DEBUG)
35
+ self.logger = logger
36
+ self.logger.setLevel(logging.DEBUG)
37
+ self.logger.addHandler(self._handler)
38
+ self._closed = False
39
+
40
+ def write_event(self, event: str, **values: Any) -> None:
41
+ payload = {"event": event, "timestamp": utc_now(), **values}
42
+ with self._lock:
43
+ if self._closed:
44
+ return
45
+ self._stream.write(json.dumps(payload, ensure_ascii=False, default=str) + "\n")
46
+ self._stream.flush()
47
+
48
+ def close(self) -> None:
49
+ with self._lock:
50
+ if self._closed:
51
+ return
52
+ self._closed = True
53
+ self.logger.removeHandler(self._handler)
54
+ self._handler.close()
55
+ self._stream.close()
56
+
57
+
58
+ class _FlushFileHandler(logging.Handler):
59
+ def __init__(self, stream: Any) -> None:
60
+ super().__init__()
61
+ self.stream = stream
62
+ self._lock = threading.RLock()
63
+
64
+ def emit(self, record: logging.LogRecord) -> None:
65
+ try:
66
+ with self._lock:
67
+ self.stream.write(self.format(record) + "\n")
68
+ self.stream.flush()
69
+ except Exception:
70
+ self.handleError(record)
71
+
72
+ def close(self) -> None:
73
+ try:
74
+ self.stream.flush()
75
+ finally:
76
+ super().close()
@@ -10,12 +10,14 @@ import uuid
10
10
  from concurrent.futures import Future, ThreadPoolExecutor
11
11
  from contextlib import asynccontextmanager
12
12
  from dataclasses import dataclass, field, replace
13
+ from pathlib import Path
13
14
  from typing import Any, Callable
14
15
 
15
16
  from .context import ExecutionContext, ProgressSnapshot, utc_now
16
17
  from .errors import (ExecutionCancelled, ExecutionNotFinished,
17
18
  ExecutionNotFound, IdempotencyConflict)
18
19
  from .models import AlgorithmRequest, AlgorithmResult
20
+ from .log_manager import ExecutionLog
19
21
  from .release import ReleaseManifest
20
22
  from .runner import AlgorithmRunner
21
23
  from .webui_app import announce_webui, mount_webui
@@ -34,6 +36,8 @@ logger = logging.getLogger(__name__)
34
36
  class ExecutionRecord:
35
37
  execution_id: str
36
38
  request: AlgorithmRequest
39
+ job_id: str
40
+ task_id: str
37
41
  accepted_at: str
38
42
  state: str = "accepted"
39
43
  started_at: str | None = None
@@ -41,6 +45,7 @@ class ExecutionRecord:
41
45
  result: AlgorithmResult | None = None
42
46
  error: str | None = None
43
47
  context: ExecutionContext | None = None
48
+ log: ExecutionLog | None = None
44
49
  future: Future[None] | None = None
45
50
  attempt: int = 0
46
51
  max_attempts: int = 1
@@ -113,8 +118,11 @@ class ExecutionManager:
113
118
  self,
114
119
  runner: AlgorithmRunner,
115
120
  *,
121
+ default_parameters: dict[str, Any] | None = None,
116
122
  max_concurrent_executions: int = 1,
117
123
  scratch_dir: str | None = None,
124
+ repository: str | Path | None = None,
125
+ log_dir: str | Path | None = None,
118
126
  gpu_ids: list[int] | None = None,
119
127
  runner_factory: RunnerFactory | None = None,
120
128
  max_attempts: int = MAX_ATTEMPTS,
@@ -144,7 +152,15 @@ class ExecutionManager:
144
152
  self._runner_factory = runner_factory or (
145
153
  lambda: AlgorithmRunner(algorithm_class())
146
154
  )
155
+ if default_parameters is not None and not isinstance(default_parameters, dict):
156
+ raise ValueError("default_parameters must be a JSON object")
157
+ self.default_parameters = dict(default_parameters or {})
147
158
  self.scratch_dir = scratch_dir
159
+ self.log_dir = (
160
+ Path(log_dir).expanduser()
161
+ if log_dir is not None
162
+ else (Path(repository).expanduser() / "logs" if repository is not None else None)
163
+ )
148
164
  self.gpu_ids = configured_gpu_ids
149
165
  self.max_concurrent_executions = max_concurrent_executions
150
166
  self.max_attempts = max_attempts
@@ -180,9 +196,16 @@ class ExecutionManager:
180
196
  request: AlgorithmRequest,
181
197
  *,
182
198
  idempotency_key: str | None = None,
199
+ job_id: str | None = None,
200
+ task_id: str | None = None,
183
201
  ) -> dict[str, Any]:
184
202
  if self.gpu_ids is not None:
185
203
  request = replace(request, gpu_ids=self.gpu_ids)
204
+ if self.default_parameters:
205
+ request = replace(
206
+ request,
207
+ parameters={**self.default_parameters, **request.parameters},
208
+ )
186
209
  self.start()
187
210
  canonical_request = json.dumps(
188
211
  request.to_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
@@ -201,9 +224,13 @@ class ExecutionManager:
201
224
  "acceptedAt": record.accepted_at,
202
225
  }
203
226
  execution_id = f"exec-{uuid.uuid4().hex}"
227
+ resolved_job_id = str(job_id or execution_id)
228
+ resolved_task_id = str(task_id or execution_id)
204
229
  record = ExecutionRecord(
205
230
  execution_id=execution_id,
206
231
  request=request,
232
+ job_id=resolved_job_id,
233
+ task_id=resolved_task_id,
207
234
  accepted_at=utc_now(),
208
235
  max_attempts=self.max_attempts,
209
236
  )
@@ -217,6 +244,30 @@ class ExecutionManager:
217
244
  ),
218
245
  progress_callback=record.add_progress,
219
246
  )
247
+ if self.log_dir is not None:
248
+ record.log = ExecutionLog(
249
+ log_dir=self.log_dir,
250
+ job_id=record.job_id,
251
+ execution_id=record.execution_id,
252
+ logger=record.context.logger,
253
+ )
254
+ workspace = record.request.workspace or {}
255
+ record.log.write_event(
256
+ "request",
257
+ jobId=record.job_id,
258
+ taskId=record.task_id,
259
+ job_id=record.job_id,
260
+ task_id=record.task_id,
261
+ executionId=record.execution_id,
262
+ execution_id=record.execution_id,
263
+ inputs=[item.to_dict() for item in record.request.inputs],
264
+ inputRoot=workspace.get("inputRoot"),
265
+ outputRoot=workspace.get("outputRoot"),
266
+ scratchRoot=workspace.get("scratchRoot") or self.scratch_dir,
267
+ merge=record.request.merge,
268
+ parameters=record.request.parameters,
269
+ gpuIds=record.request.gpu_ids,
270
+ )
220
271
  with self._lock:
221
272
  if self._closed:
222
273
  raise RuntimeError("execution manager is closed")
@@ -328,6 +379,9 @@ class ExecutionManager:
328
379
  cancelled_before_start = bool(record.future and record.future.cancel())
329
380
  if cancelled_before_start:
330
381
  record.set_state("cancelled", error=reason)
382
+ if record.log is not None:
383
+ record.log.write_event("result", state="cancelled", error=reason)
384
+ record.log.close()
331
385
  return {
332
386
  "executionId": execution_id,
333
387
  "state": record.state,
@@ -349,6 +403,9 @@ class ExecutionManager:
349
403
  record.context.cancel("service shutdown")
350
404
  if record.future and record.future.cancel():
351
405
  record.set_state("cancelled", error="service shutdown")
406
+ if record.log is not None:
407
+ record.log.write_event("result", state="cancelled", error="service shutdown")
408
+ record.log.close()
352
409
  self._executor.shutdown(wait=True, cancel_futures=True)
353
410
  self.runner.close()
354
411
 
@@ -362,17 +419,28 @@ class ExecutionManager:
362
419
  def _run(self, record: ExecutionRecord) -> None:
363
420
  record.set_state("running")
364
421
  assert record.context is not None
422
+ if record.log is not None:
423
+ record.log.write_event("started", jobId=record.job_id, taskId=record.task_id)
365
424
  try:
366
425
  result = self._run_with_restarts(record)
367
426
  except ExecutionCancelled as exc:
368
427
  record.context.mark_unfinished("cancelled", str(exc))
369
428
  record.set_state("cancelled", error=str(exc))
429
+ if record.log is not None:
430
+ record.log.write_event("result", state="cancelled", error=str(exc))
370
431
  except Exception as exc:
371
432
  error = f"{type(exc).__name__}: {exc}"
372
433
  record.context.mark_unfinished("failed", error)
373
434
  record.set_state("failed", error=error)
435
+ if record.log is not None:
436
+ record.log.write_event("result", state="failed", error=error)
374
437
  else:
375
438
  record.set_state(result.status, result=result)
439
+ if record.log is not None:
440
+ record.log.write_event("result", state=record.state, result=result.to_dict())
441
+ finally:
442
+ if record.log is not None:
443
+ record.log.close()
376
444
 
377
445
  def _run_with_restarts(self, record: ExecutionRecord) -> AlgorithmResult:
378
446
  assert record.context is not None
@@ -585,7 +653,12 @@ def create_app(
585
653
  async def create_execution(request: Request) -> Any:
586
654
  try:
587
655
  body = await request.json()
588
- execution_request = AlgorithmRequest.from_dict(body)
656
+ if not isinstance(body, dict):
657
+ raise ValueError("request body must be a mapping")
658
+ job_id = next((body.get(name) for name in ("jobId", "job_id") if body.get(name)), None)
659
+ task_id = next((body.get(name) for name in ("taskId", "task_id") if body.get(name)), None)
660
+ request_body = {key: value for key, value in body.items() if key not in {"jobId", "job_id", "taskId", "task_id"}}
661
+ execution_request = AlgorithmRequest.from_dict(request_body)
589
662
  if execution_request.workspace is None:
590
663
  raise ValueError("workspace is required")
591
664
  except Exception as exc:
@@ -593,9 +666,14 @@ def create_app(
593
666
  status_code=422,
594
667
  content={"error": f"{type(exc).__name__}: {exc}"},
595
668
  )
669
+ idempotency_key = request.headers.get("Idempotency-Key")
670
+ job_id = job_id or next((request.headers.get(name) for name in ("X-LDP-Job-ID", "X-Job-ID", "Job-ID") if request.headers.get(name)), None)
671
+ task_id = task_id or next((request.headers.get(name) for name in ("X-LDP-Task-ID", "X-Task-ID", "Task-ID") if request.headers.get(name)), None)
596
672
  return manager.submit(
597
673
  execution_request,
598
- idempotency_key=request.headers.get("Idempotency-Key"),
674
+ idempotency_key=idempotency_key,
675
+ job_id=job_id or idempotency_key,
676
+ task_id=task_id,
599
677
  )
600
678
 
601
679
  @application.get("/v1/executions/{execution_id}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ls-algorithm-plugin-sdk
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Protocol-independent runtime SDK for dataset algorithms
5
5
  Author: Ling Robotics
6
6
  License: Proprietary
@@ -6,7 +6,9 @@ src/algorithm_plugin_sdk/cli.py
6
6
  src/algorithm_plugin_sdk/context.py
7
7
  src/algorithm_plugin_sdk/deployment.py
8
8
  src/algorithm_plugin_sdk/errors.py
9
+ src/algorithm_plugin_sdk/gpu_isolation.py
9
10
  src/algorithm_plugin_sdk/loader.py
11
+ src/algorithm_plugin_sdk/log_manager.py
10
12
  src/algorithm_plugin_sdk/models.py
11
13
  src/algorithm_plugin_sdk/registration.py
12
14
  src/algorithm_plugin_sdk/release.py
@@ -34,6 +36,7 @@ src/ls_algorithm_plugin_sdk.egg-info/top_level.txt
34
36
  tests/test_cli.py
35
37
  tests/test_configure.py
36
38
  tests/test_deployment.py
39
+ tests/test_gpu_isolation.py
37
40
  tests/test_models.py
38
41
  tests/test_registration.py
39
42
  tests/test_release.py
@@ -142,6 +142,7 @@ class ConfigureTests(unittest.TestCase):
142
142
  "--scratch-dir", "scratch",
143
143
  "--gpu-ids", "2,0",
144
144
  "--service-token", "secret",
145
+ "--default-parameters", '{"batchSize": 8, "mode": "fast"}',
145
146
  "--env", "DEMO_MODE=fast",
146
147
  "--force",
147
148
  ]
@@ -156,6 +157,10 @@ class ConfigureTests(unittest.TestCase):
156
157
  self.assertEqual(args.scratch_dir, "scratch")
157
158
  self.assertEqual(args.gpu_ids, [2, 0])
158
159
  self.assertEqual(args.service_token, "secret")
160
+ self.assertEqual(
161
+ args.default_parameters,
162
+ {"batchSize": 8, "mode": "fast"},
163
+ )
159
164
  self.assertEqual(args.env, [("DEMO_MODE", "fast")])
160
165
  self.assertTrue(args.force)
161
166
 
@@ -200,15 +205,29 @@ class ConfigureTests(unittest.TestCase):
200
205
  host="0.0.0.0",
201
206
  port=9000,
202
207
  webui=True,
203
- max_concurrency=1,
208
+ max_concurrency=2,
204
209
  scratch_dir=None,
205
210
  token=None,
206
211
  registration=None,
207
212
  gpu_ids=[3, 1],
208
213
  environment={"DEMO_MODE": "fast"},
214
+ default_parameters={},
209
215
  )
210
216
  config.save.assert_called_once_with()
211
217
 
218
+ def test_defaults_max_concurrency_to_one_without_gpus(self) -> None:
219
+ repository = Path("/srv/demo").resolve()
220
+ args = build_parser().parse_args(["configure", "--local"])
221
+ config = Mock()
222
+
223
+ with patch(
224
+ "algorithm_plugin_sdk.cli_impl.configure.DeploymentConfig.generate",
225
+ return_value=config,
226
+ ) as generate:
227
+ generate_config(args, repository)
228
+
229
+ self.assertEqual(generate.call_args.kwargs["max_concurrency"], 1)
230
+
212
231
  def test_requires_complete_registration_unless_local(self) -> None:
213
232
  with patch.dict(os.environ, {}, clear=True):
214
233
  args = build_parser().parse_args(["configure"])
@@ -323,10 +342,11 @@ class ConfigureTests(unittest.TestCase):
323
342
  "[Service]\n"
324
343
  "Type=simple\n"
325
344
  "User=root\n"
326
- "WorkingDirectory=/srv/demo algorithm\n"
345
+ "WorkingDirectory=/srv/demo algorithm/jobs\n"
327
346
  'ExecStart="/srv/demo algorithm/.venv/bin/algorithm-plugin" '
328
347
  'serve --config "/srv/demo algorithm/algorithm-plugin.json"\n'
329
348
  "Environment=PYTHONUNBUFFERED=1\n"
349
+ "LimitNOFILE=524288\n"
330
350
  "Restart=on-failure\n"
331
351
  "RestartSec=5s\n"
332
352
  "\n"
@@ -490,6 +510,9 @@ class ConfigureTests(unittest.TestCase):
490
510
  "algorithm_plugin_sdk.cli_impl.configure.inspect_systemd_unit",
491
511
  return_value=(unit_path, True),
492
512
  ) as inspect_unit,
513
+ patch(
514
+ "algorithm_plugin_sdk.cli_impl.configure.Path.mkdir",
515
+ ) as mkdir,
493
516
  patch(
494
517
  "algorithm_plugin_sdk.cli_impl.configure.generate_config",
495
518
  return_value=config,
@@ -505,10 +528,19 @@ class ConfigureTests(unittest.TestCase):
505
528
  self.assertEqual(result, 0)
506
529
  require_root.assert_called_once_with()
507
530
  validate_runtime.assert_called_once_with(repository)
531
+ mkdir.assert_called_once_with(exist_ok=True)
508
532
  generate.assert_called_once_with(args, repository)
509
533
  inspect_unit.assert_called_once()
510
534
  write_unit.assert_called_once()
511
535
  self.assertIn("User=root", inspect_unit.call_args.kwargs["content"])
536
+ self.assertIn(
537
+ "LimitNOFILE=524288",
538
+ inspect_unit.call_args.kwargs["content"],
539
+ )
540
+ self.assertIn(
541
+ "WorkingDirectory=/srv/demo/jobs",
542
+ inspect_unit.call_args.kwargs["content"],
543
+ )
512
544
  self.assertEqual(
513
545
  output.getvalue(),
514
546
  "Configured repository: /srv/demo\n"
@@ -70,6 +70,7 @@ class DeploymentConfigTests(unittest.TestCase):
70
70
  registration=None,
71
71
  gpu_ids=[3, 1],
72
72
  environment={"DEMO_SETTING": "yes"},
73
+ default_parameters={"batchSize": 8, "mode": "fast"},
73
74
  )
74
75
  config.save()
75
76
  loaded = DeploymentConfig.load(config.path)
@@ -77,6 +78,10 @@ class DeploymentConfigTests(unittest.TestCase):
77
78
  self.assertEqual(loaded.payload["schemaVersion"], CONFIG_SCHEMA)
78
79
  self.assertEqual(loaded.payload["algorithm"]["type"], "demo_algorithm")
79
80
  self.assertEqual(loaded.payload["algorithm"]["version"], "1.2.3")
81
+ self.assertEqual(
82
+ loaded.payload["service"]["defaultParameters"],
83
+ {"batchSize": 8, "mode": "fast"},
84
+ )
80
85
  self.assertEqual(loaded.payload["service"]["gpuIds"], [3, 1])
81
86
  manifest_path = root / "release-manifest.json"
82
87
  manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
@@ -0,0 +1,38 @@
1
+ import os
2
+ import unittest
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+
6
+ from algorithm_plugin_sdk.cli_impl.configure import render_systemd_unit
7
+ from algorithm_plugin_sdk.gpu_isolation import apply_gpu_isolation
8
+
9
+
10
+ class GpuIsolationTests(unittest.TestCase):
11
+ @patch.dict(os.environ, {}, clear=True)
12
+ def test_masks_physical_ids_and_returns_local_ordinals(self) -> None:
13
+ self.assertEqual(apply_gpu_isolation([3, 1]), [0, 1])
14
+ self.assertEqual(os.environ["CUDA_VISIBLE_DEVICES"], "3,1")
15
+
16
+ @patch.dict(os.environ, {"CUDA_VISIBLE_DEVICES": "7"}, clear=True)
17
+ def test_empty_gpu_ids_preserve_existing_mask(self) -> None:
18
+ self.assertEqual(apply_gpu_isolation(None), [])
19
+ self.assertEqual(apply_gpu_isolation([]), [])
20
+ self.assertEqual(os.environ["CUDA_VISIBLE_DEVICES"], "7")
21
+
22
+ def test_rejects_invalid_gpu_ids(self) -> None:
23
+ for gpu_ids in ([1, 1], [-1], [True]):
24
+ with self.subTest(gpu_ids=gpu_ids), self.assertRaises(ValueError):
25
+ apply_gpu_isolation(gpu_ids)
26
+
27
+ def test_systemd_unit_sets_physical_gpu_mask(self) -> None:
28
+ unit = render_systemd_unit(
29
+ repository=Path("/srv/demo"),
30
+ config_path=Path("/srv/demo/algorithm-plugin.json"),
31
+ service_name="demo",
32
+ gpu_ids=[3, 1],
33
+ )
34
+ self.assertIn("Environment=CUDA_VISIBLE_DEVICES=3,1", unit)
35
+
36
+
37
+ if __name__ == "__main__":
38
+ unittest.main()
@@ -1,3 +1,4 @@
1
+ import os
1
2
  import unittest
2
3
  from io import StringIO
3
4
  from unittest.mock import Mock, patch
@@ -115,6 +116,31 @@ class AlgorithmRunnerTests(unittest.TestCase):
115
116
 
116
117
  self.assertEqual(request.gpu_ids, [3, 1])
117
118
  self.assertEqual(request.inputs[0].input_dataset, "/data/input")
119
+ def test_execute_isolates_and_maps_cli_gpu_ids(self) -> None:
120
+ args = build_parser().parse_args(
121
+ [
122
+ "run",
123
+ "test:Algorithm",
124
+ "--input",
125
+ '{"input_dataset":"/data/input","output":"/data/output"}',
126
+ "--gpu-ids",
127
+ "3,1",
128
+ ]
129
+ )
130
+ algorithm = RecordingAlgorithm()
131
+
132
+ with (
133
+ patch.dict(os.environ, {}, clear=True),
134
+ patch(
135
+ "algorithm_plugin_sdk.cli_impl.run.load_algorithm",
136
+ return_value=algorithm,
137
+ ),
138
+ ):
139
+ self.assertEqual(execute_run(args), 0)
140
+ self.assertEqual(os.environ["CUDA_VISIBLE_DEVICES"], "3,1")
141
+
142
+ self.assertEqual(algorithm.calls[0].gpu_ids, [0, 1])
143
+
118
144
 
119
145
  def test_serve_uses_service_restart_defaults(self) -> None:
120
146
  args = build_parser().parse_args(["serve", "test:Algorithm"])
@@ -37,6 +37,26 @@ class ExecutionManagerTests(unittest.TestCase):
37
37
  manager.close()
38
38
  self.assertEqual(algorithm.calls[0].gpu_ids, [3, 1])
39
39
 
40
+ def test_fills_missing_parameters_from_configured_defaults(self) -> None:
41
+ algorithm = RecordingAlgorithm()
42
+ manager = ExecutionManager(
43
+ AlgorithmRunner(algorithm),
44
+ default_parameters={"batchSize": 8, "mode": "accurate"},
45
+ )
46
+ request = AlgorithmRequest(
47
+ inputs=self._request().inputs,
48
+ parameters={"batchSize": 16},
49
+ )
50
+
51
+ accepted = manager.submit(request)
52
+ manager._record(accepted["executionId"]).future.result(timeout=2)
53
+ manager.close()
54
+
55
+ self.assertEqual(
56
+ algorithm.calls[0].parameters,
57
+ {"batchSize": 16, "mode": "accurate"},
58
+ )
59
+
40
60
  def test_runs_the_same_algorithm_contract(self) -> None:
41
61
  manager = ExecutionManager(AlgorithmRunner(RecordingAlgorithm()))
42
62
  accepted = manager.submit(