ls-algorithm-plugin-sdk 0.3.0__py3-none-any.whl → 0.3.3__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.
@@ -1,11 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import ipaddress
4
5
  import os
5
6
  import re
7
+ import socket
6
8
  import subprocess
7
9
  import sys
8
10
  from pathlib import Path
11
+ from urllib.parse import urlparse
9
12
 
10
13
  from ..deployment import (
11
14
  DEFAULT_CONFIG_NAME,
@@ -13,10 +16,15 @@ from ..deployment import (
13
16
  repository_root,
14
17
  stable_instance_key,
15
18
  )
16
- from .parsing import environment, gpu_ids
19
+ from .parsing import environment, gpu_ids, json_object
17
20
 
18
21
 
19
22
  SYSTEMD_UNIT_DIR = Path("/etc/systemd/system")
23
+ INPUT_ROOT = Path("/mnt/ldp_uploads/ldp-uploads")
24
+ PRIVATE_IPV4_NETWORKS = tuple(
25
+ ipaddress.ip_network(network)
26
+ for network in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
27
+ )
20
28
 
21
29
  SERVICE_NAME_PATTERN = re.compile(
22
30
  r"^[A-Za-z0-9][A-Za-z0-9_.@-]*$"
@@ -48,7 +56,11 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
48
56
  action=argparse.BooleanOptionalAction,
49
57
  default=True,
50
58
  )
51
- 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
+ )
52
64
  parser.add_argument("--scratch-dir")
53
65
  parser.add_argument(
54
66
  "--gpu-ids",
@@ -56,6 +68,12 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
56
68
  default=[],
57
69
  help="GPU IDs injected into every service execution",
58
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
+ )
59
77
  parser.add_argument("--service-token")
60
78
  parser.add_argument(
61
79
  "--compute-url",
@@ -65,18 +83,6 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
65
83
  "--api-key",
66
84
  default=os.getenv("LDP_INTERNAL_API_KEY"),
67
85
  )
68
- parser.add_argument(
69
- "--public-url",
70
- default=os.getenv("LDP_PLUGIN_PUBLIC_URL"),
71
- )
72
- parser.add_argument(
73
- "--input-root",
74
- default=os.getenv("LDP_JOB_INPUT_ROOT"),
75
- )
76
- parser.add_argument(
77
- "--workspace-root",
78
- default=os.getenv("LDP_JOB_WORKSPACE_ROOT"),
79
- )
80
86
  parser.add_argument(
81
87
  "--instance-key",
82
88
  default=os.getenv("LDP_PLUGIN_INSTANCE_KEY"),
@@ -85,14 +91,6 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
85
91
  "--cluster",
86
92
  default=os.getenv("LDP_CLUSTER"),
87
93
  )
88
- parser.add_argument(
89
- "--namespace",
90
- default=os.getenv("POD_NAMESPACE"),
91
- )
92
- parser.add_argument(
93
- "--node-name",
94
- default=os.getenv("NODE_NAME"),
95
- )
96
94
  parser.add_argument(
97
95
  "--local",
98
96
  action="store_true",
@@ -105,10 +103,6 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
105
103
  default=[],
106
104
  metavar="NAME=VALUE",
107
105
  )
108
- parser.add_argument(
109
- "--service-name",
110
- help="systemd unit name without .service; defaults to repository name",
111
- )
112
106
  parser.add_argument(
113
107
  "--force",
114
108
  action="store_true",
@@ -119,33 +113,35 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
119
113
 
120
114
  def resolve_service_name(
121
115
  repository: Path,
122
- requested: str | None,
123
116
  ) -> str:
124
- name = requested or repository.name
117
+ name = repository.name
125
118
  if name.endswith(".service"):
126
119
  raise ValueError(
127
- "service name must not include the .service suffix"
120
+ "repository name must not include the .service suffix"
128
121
  )
129
122
  if not SERVICE_NAME_PATTERN.fullmatch(name):
130
123
  raise ValueError(
131
- "service name may contain only letters, numbers, "
124
+ "repository name may contain only letters, numbers, "
132
125
  "underscore, dot, @, and hyphen"
133
126
  )
134
127
  return name
135
128
 
136
129
 
137
- def _quote_systemd_path(value: Path) -> str:
130
+ def _escape_systemd_path(value: Path) -> str:
138
131
  text = str(value)
139
132
  if "\n" in text or "\r" in text or "\0" in text:
140
133
  raise ValueError(
141
134
  f"systemd path contains control characters: {value}"
142
135
  )
143
- escaped = (
136
+ return (
144
137
  text.replace("\\", "\\\\")
145
138
  .replace('"', '\\"')
146
139
  .replace("%", "%%")
147
140
  )
148
- return f'"{escaped}"'
141
+
142
+
143
+ def _quote_systemd_path(value: Path) -> str:
144
+ return f'"{_escape_systemd_path(value)}"'
149
145
 
150
146
 
151
147
  def render_systemd_unit(
@@ -153,10 +149,18 @@ def render_systemd_unit(
153
149
  repository: Path,
154
150
  config_path: Path,
155
151
  service_name: str,
152
+ gpu_ids: list[int] | None = None,
156
153
  ) -> str:
157
154
  root = repository.resolve()
155
+ working_directory = root / "jobs"
158
156
  launcher = root / ".venv" / "bin" / "algorithm-plugin"
159
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
+ )
160
164
  return "\n".join(
161
165
  (
162
166
  "[Unit]",
@@ -167,12 +171,13 @@ def render_systemd_unit(
167
171
  "[Service]",
168
172
  "Type=simple",
169
173
  "User=root",
170
- f"WorkingDirectory={_quote_systemd_path(root)}",
174
+ f"WorkingDirectory={_escape_systemd_path(working_directory)}",
171
175
  (
172
176
  f"ExecStart={_quote_systemd_path(launcher)} "
173
177
  f"serve --config {_quote_systemd_path(config)}"
174
178
  ),
175
- "Environment=PYTHONUNBUFFERED=1",
179
+ *environment,
180
+ "LimitNOFILE=524288",
176
181
  "Restart=on-failure",
177
182
  "RestartSec=5s",
178
183
  "",
@@ -317,6 +322,35 @@ def resolve_config_path(
317
322
  return path.resolve()
318
323
 
319
324
 
325
+ def resolve_private_ipv4(compute_url: str) -> str:
326
+ parsed = urlparse(compute_url)
327
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
328
+ raise ValueError("compute URL must be an http or https URL")
329
+ try:
330
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
331
+ except ValueError as exc:
332
+ raise ValueError("compute URL has an invalid port") from exc
333
+
334
+ try:
335
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
336
+ probe.connect((parsed.hostname, port))
337
+ value = probe.getsockname()[0]
338
+ except OSError as exc:
339
+ raise RuntimeError(
340
+ f"cannot resolve the local IP used to reach {parsed.hostname}: {exc}"
341
+ ) from exc
342
+
343
+ address = ipaddress.ip_address(value)
344
+ if not isinstance(address, ipaddress.IPv4Address) or not any(
345
+ address in network for network in PRIVATE_IPV4_NETWORKS
346
+ ):
347
+ raise RuntimeError(
348
+ f"the local IP used to reach {parsed.hostname} is not a private IPv4: "
349
+ f"{address}"
350
+ )
351
+ return str(address)
352
+
353
+
320
354
  def registration_from_args(
321
355
  args: argparse.Namespace,
322
356
  repository: Path,
@@ -327,12 +361,9 @@ def registration_from_args(
327
361
  supplied = {
328
362
  "computeUrl": args.compute_url,
329
363
  "apiKey": args.api_key,
330
- "publicUrl": args.public_url,
331
- "inputRoot": args.input_root,
332
- "workspaceRoot": args.workspace_root,
364
+ "inputRoot": str(INPUT_ROOT),
365
+ "workspaceRoot": str(repository.resolve()),
333
366
  "cluster": args.cluster,
334
- "namespace": args.namespace,
335
- "nodeName": args.node_name,
336
367
  }
337
368
  missing = [
338
369
  name
@@ -350,6 +381,9 @@ def registration_from_args(
350
381
  name: str(value)
351
382
  for name, value in supplied.items()
352
383
  }
384
+ private_ipv4 = resolve_private_ipv4(registration["computeUrl"])
385
+ registration["publicUrl"] = f"http://{private_ipv4}:{args.port}"
386
+ registration["nodeName"] = private_ipv4
353
387
  registration["instanceKey"] = stable_instance_key(
354
388
  repository / ".algorithm-plugin" / "instance-key",
355
389
  requested=args.instance_key,
@@ -361,6 +395,11 @@ def generate_config(
361
395
  args: argparse.Namespace,
362
396
  repository: Path,
363
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
+ )
364
403
  config = DeploymentConfig.generate(
365
404
  output=args.config_output,
366
405
  root=repository,
@@ -368,12 +407,13 @@ def generate_config(
368
407
  host=args.host,
369
408
  port=args.port,
370
409
  webui=args.webui,
371
- max_concurrency=args.max_concurrency,
410
+ max_concurrency=max_concurrency,
372
411
  scratch_dir=args.scratch_dir,
373
412
  token=args.service_token,
374
413
  registration=registration_from_args(args, repository),
375
414
  gpu_ids=args.gpu_ids,
376
415
  environment=dict(args.env),
416
+ default_parameters=args.default_parameters,
377
417
  )
378
418
  config.save()
379
419
  return config
@@ -383,10 +423,7 @@ def execute(args: argparse.Namespace) -> int:
383
423
  require_root()
384
424
  repository = validate_repository(args.repository)
385
425
  validate_configure_runtime(repository)
386
- service_name = resolve_service_name(
387
- repository,
388
- args.service_name,
389
- )
426
+ service_name = resolve_service_name(repository)
390
427
  config_path = resolve_config_path(
391
428
  repository,
392
429
  args.config_output,
@@ -395,6 +432,7 @@ def execute(args: argparse.Namespace) -> int:
395
432
  repository=repository,
396
433
  config_path=config_path,
397
434
  service_name=service_name,
435
+ gpu_ids=args.gpu_ids,
398
436
  )
399
437
 
400
438
  inspect_systemd_unit(
@@ -402,6 +440,7 @@ def execute(args: argparse.Namespace) -> int:
402
440
  content=unit,
403
441
  force=args.force,
404
442
  )
443
+ (repository / "jobs").mkdir(exist_ok=True)
405
444
  config = generate_config(args, repository)
406
445
  unit_path, changed = write_systemd_unit(
407
446
  service_name=service_name,
@@ -418,4 +457,5 @@ def execute(args: argparse.Namespace) -> int:
418
457
  print(" sudo systemctl daemon-reload")
419
458
  print(f" sudo systemctl enable {service_name}.service")
420
459
  print(f" sudo systemctl restart {service_name}.service")
421
- return 0
460
+ print(f" sudo systemctl status {service_name}.service --no-pager")
461
+ return 0
@@ -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)
@@ -154,6 +155,8 @@ class DeploymentConfig:
154
155
  instance,
155
156
  repository=repository,
156
157
  )
158
+ if registration is not None:
159
+ registration = {**registration, "namespace": release.name}
157
160
  release_path = release.save(
158
161
  repository / "release-manifest.json"
159
162
  )
@@ -188,6 +191,7 @@ class DeploymentConfig:
188
191
  "scratchDir": _absolute(scratch_dir, repository),
189
192
  "gpuIds": configured_gpu_ids,
190
193
  "token": token or None,
194
+ "defaultParameters": dict(default_parameters or {}),
191
195
  },
192
196
  "registration": registration,
193
197
  "environment": dict(sorted((environment or {}).items())),
@@ -271,6 +275,16 @@ class DeploymentConfig:
271
275
  ) or len(gpu_ids) != len(set(gpu_ids)):
272
276
  raise RuntimeError("service gpuIds must be unique non-negative integers")
273
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
+
274
288
  def _validate_registration(self) -> None:
275
289
  registration = self.payload.get("registration")
276
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.0
3
+ Version: 0.3.3
4
4
  Summary: Protocol-independent runtime SDK for dataset algorithms
5
5
  Author: Ling Robotics
6
6
  License: Proprietary
@@ -15,10 +15,6 @@ Requires-Dist: build>=1.2; extra == "dev"
15
15
 
16
16
  # Algorithm Plugin SDK
17
17
 
18
- 算法仓库只实现一个 `Algorithm` 类并声明 entry point。SDK 统一提供单次运行、
19
- WebUI、HTTP service、release manifest、compute 注册和心跳。算法仓库不需要
20
- Dockerfile 或 Web 框架代码。
21
-
22
18
  ## 算法侧最小接口
23
19
 
24
20
  ```python
@@ -30,8 +26,7 @@ class MyAlgorithm(Algorithm):
30
26
  def execute(self, request, context): ...
31
27
  ```
32
28
 
33
- 算法包需要声明 `algorithm_plugin_sdk.algorithms` entry point。同一个 `execute()`
34
- 会被所有运行方式复用。
29
+ 算法包需要声明 `algorithm_plugin_sdk.algorithms` entry point。
35
30
 
36
31
  ## 单次运行
37
32
 
@@ -49,10 +44,9 @@ algorithm-plugin run camera-space-mano \
49
44
 
50
45
  目标算法仓库必须满足:
51
46
 
52
- - 仓库本身是 Git 工作树根目录,并且至少有一个 commit。
47
+ - 仓库本身是 Git 工作树根目录。
53
48
  - 仓库包含自己的 `.venv`。
54
49
  - `<repository>/.venv/bin/algorithm-plugin` 存在且可执行。
55
- - 该启动器的 `run --help` 和 `serve --help` 均可正常加载。
56
50
 
57
51
  建议将生成的 `release-manifest.json` 和 `algorithm-plugin.json` 加入算法仓库的
58
52
  `.gitignore`,不要手动编辑或提交它们。
@@ -64,16 +58,11 @@ sudo /srv/camera-space-mano/.venv/bin/algorithm-plugin configure \
64
58
  --repository /srv/camera-space-mano \
65
59
  --compute-url http://compute:5180 \
66
60
  --api-key "$LDP_INTERNAL_API_KEY" \
67
- --public-url http://10.0.0.4:9000 \
68
- --input-root /data/jobs/input \
69
- --workspace-root /data/jobs/workspace \
61
+ --port 9030 \
70
62
  --cluster production \
71
- --namespace camera-space-mano \
72
- --node-name 10.0.0.4 \
73
63
  --gpu-ids 0,1
74
64
  ```
75
65
 
76
- service name 默认使用仓库目录名,也可以通过 `--service-name` 指定。
77
66
  重复执行 configure 需要显式提供 `--force`。
78
67
 
79
68
  确认输出后执行:
@@ -82,7 +71,7 @@ service name 默认使用仓库目录名,也可以通过 `--service-name` 指
82
71
  sudo systemctl daemon-reload
83
72
  sudo systemctl enable camera-space-mano.service
84
73
  sudo systemctl restart camera-space-mano.service
85
- sudo systemctl status camera-space-mano.service
74
+ sudo systemctl status camera-space-mano.service --no-pager
86
75
  ```
87
76
 
88
77
  ## serve
@@ -95,6 +84,6 @@ sudo systemctl status camera-space-mano.service
95
84
  ## 安装与测试
96
85
 
97
86
  ```bash
98
- python -m pip install '.[service]'
87
+ python -m pip install .
99
88
  python -m unittest discover -s tests -v
100
89
  ```
@@ -2,20 +2,22 @@ algorithm_plugin_sdk/__init__.py,sha256=OKj2VcNXTwZmRgPlECI6QUBlxW1YLtflr4RAgs6w
2
2
  algorithm_plugin_sdk/algorithm.py,sha256=INjX-ygMQfHk8D1AZbEEdCw1P4VNQLGF989lP1J1H1k,1025
3
3
  algorithm_plugin_sdk/cli.py,sha256=V9HFJ98iyhM1q6PZqd-366g1AoKuieC_Fn4u9RJcMNI,1252
4
4
  algorithm_plugin_sdk/context.py,sha256=Kj-eDZg7PgX6xNzrWrQs37UfepTP8fXv4_8hCgsAIDA,11016
5
- algorithm_plugin_sdk/deployment.py,sha256=hFX6kJXia2gF2mOCDJlgWK-BAUT6tAvr2t70u32mg1A,13823
5
+ algorithm_plugin_sdk/deployment.py,sha256=nx4-26vs5OqO6miBf6GegZIhdvOgFaw9MdjpTHZNDPo,14583
6
6
  algorithm_plugin_sdk/errors.py,sha256=DNmI2c36Pd7K6tTaonqfEgpBzwX0o6pXzYrc8r2Z--k,730
7
+ algorithm_plugin_sdk/gpu_isolation.py,sha256=toXUQ1vfzfLPiideJ8dWkfa9NW2Lc8V0sbNx-75YTjg,794
7
8
  algorithm_plugin_sdk/loader.py,sha256=XKmyqbJMoUMdKoo2eRQFGdbsmlz6NBgtUeIdqzumJFs,2561
9
+ algorithm_plugin_sdk/log_manager.py,sha256=ktVVvf4khqXiYENr1QRpSf7yTG4HBzxW2AOHIZElDuE,2457
8
10
  algorithm_plugin_sdk/models.py,sha256=FIwUm8QDK8L37pV7AcCMnmYlDP-2l93zp8yc0KSu4yo,12196
9
11
  algorithm_plugin_sdk/registration.py,sha256=s5oQkf12_uRjdA4CYMYM8SDQjhsk1rzDDF-3w36U_lA,10474
10
12
  algorithm_plugin_sdk/release.py,sha256=KM-kRM1oFYMgjDFnXdu6BLR1b4dsvWWDCEgTNq8cb3w,9439
11
13
  algorithm_plugin_sdk/runner.py,sha256=583Y9_7efvW5rrmM2nt316NEpCRO6kd8dKYjFRjgtCk,3183
12
- algorithm_plugin_sdk/service.py,sha256=Xj99UiCZtqtu7U4IVghAr4Mu3I9nR1AvP6vNzdD4sAk,24002
14
+ algorithm_plugin_sdk/service.py,sha256=HcO8PmtPczogi0an7irajsRCD-11GAH8pnE7rOHOLSM,28004
13
15
  algorithm_plugin_sdk/webui_app.py,sha256=Gvt2FPt0xZBjYi39sqTQqcosllifYeJam49_mGfkEVw,1530
14
16
  algorithm_plugin_sdk/cli_impl/__init__.py,sha256=uLknQVGipNd7eSJegDHZS0NVowMa0qAaOf63wFI3zHM,78
15
- algorithm_plugin_sdk/cli_impl/configure.py,sha256=acCGDJ6Khn3G8hay8ISsYVlKDmhDH1i-UpNZU5bb0fU,11668
17
+ algorithm_plugin_sdk/cli_impl/configure.py,sha256=OlYuNnPZXHbaoWKfBpFivyP8lZk2QQpvMN572hAjriA,13353
16
18
  algorithm_plugin_sdk/cli_impl/parsing.py,sha256=TDX45Spu4WMrczuzTMN9ZC2SYyHE11OPBCvN45yPsxs,1399
17
- algorithm_plugin_sdk/cli_impl/run.py,sha256=EnqqCdGWYK-iB5s1DMlGRXNpnCWA25xoAex0f4l36Ic,5893
18
- algorithm_plugin_sdk/cli_impl/serve.py,sha256=9_nJcdzerwqczGm7xTIcMYKShpmoltYD6r_nBQjAlWU,4324
19
+ algorithm_plugin_sdk/cli_impl/run.py,sha256=C4raNwhj4KZJc7_aTJeoiwNMQtgeXyMWyLTZEVUL0mM,6041
20
+ algorithm_plugin_sdk/cli_impl/serve.py,sha256=BBxjFjOI8bORzNij9DbKccAdt4zNzX_vqZyyKtliGSg,4936
19
21
  algorithm_plugin_sdk/examples/__init__.py,sha256=WxZZzUzxKhqZg3KibVY-7CB960DNjt6IfjciZ8JXbq0,57
20
22
  algorithm_plugin_sdk/examples/example_algorithm.py,sha256=j2RsVaRPxpJn3kK6_H_JqAceKTpJE8txNm-oTrH2v6E,3787
21
23
  algorithm_plugin_sdk/examples/simulated_algorithm.py,sha256=bIaneSMM09CJfV5-oGuZ5QwBDWV1u-7Fn0WDN1TuwGw,2944
@@ -23,8 +25,8 @@ algorithm_plugin_sdk/webui/__init__.py,sha256=cvtaktJXz_DYG4QbV5ppoqY2bFaMLuiUcT
23
25
  algorithm_plugin_sdk/webui/app.css,sha256=5HGDKlbUFLZMgCknlRuwCjiNcCr8poIF5YGHchyaamE,2965
24
26
  algorithm_plugin_sdk/webui/app.js,sha256=0YsXsocEY3yDseG3jh0RhzI8xo_bYmsbSxdN3FdCswQ,7657
25
27
  algorithm_plugin_sdk/webui/index.html,sha256=Z222OuaacWdbr0s-sjJq6wmg45u96tIneM5-LP55Z8I,2261
26
- ls_algorithm_plugin_sdk-0.3.0.dist-info/METADATA,sha256=YewSjuTdWCstRq4c6h4-fy-7pSbs31JRoui_J5GhV7M,2943
27
- ls_algorithm_plugin_sdk-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
28
- ls_algorithm_plugin_sdk-0.3.0.dist-info/entry_points.txt,sha256=TU0R_TxuB5OuOiZ5BHOEmUPtHvN3v8ARUW8d3PztHcE,67
29
- ls_algorithm_plugin_sdk-0.3.0.dist-info/top_level.txt,sha256=8lsgxZ8HJGLlzJ5Rt8CiVzw9Ccme286i3akbmeMjBos,21
30
- ls_algorithm_plugin_sdk-0.3.0.dist-info/RECORD,,
28
+ ls_algorithm_plugin_sdk-0.3.3.dist-info/METADATA,sha256=iLerkePM3Ff_GG0oA4TAdTsidnxYJxP1pmsSaIARMJw,2317
29
+ ls_algorithm_plugin_sdk-0.3.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
30
+ ls_algorithm_plugin_sdk-0.3.3.dist-info/entry_points.txt,sha256=TU0R_TxuB5OuOiZ5BHOEmUPtHvN3v8ARUW8d3PztHcE,67
31
+ ls_algorithm_plugin_sdk-0.3.3.dist-info/top_level.txt,sha256=8lsgxZ8HJGLlzJ5Rt8CiVzw9Ccme286i3akbmeMjBos,21
32
+ ls_algorithm_plugin_sdk-0.3.3.dist-info/RECORD,,