ls-algorithm-plugin-sdk 0.3.1__py3-none-any.whl → 0.3.4__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.
@@ -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,14 +56,34 @@ 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")
65
+ parser.add_argument(
66
+ "--local-scratch",
67
+ action="store_true",
68
+ help="override request scratchRoot with <repository>/.scratch",
69
+ )
70
+ parser.add_argument(
71
+ "--local-output",
72
+ action="store_true",
73
+ help="override request output paths with <repository>/.output/<job-id>",
74
+ )
61
75
  parser.add_argument(
62
76
  "--gpu-ids",
63
77
  type=gpu_ids,
64
78
  default=[],
65
79
  help="GPU IDs injected into every service execution",
66
80
  )
81
+ parser.add_argument(
82
+ "--default-parameters",
83
+ type=json_object,
84
+ default={},
85
+ help="JSON object used to fill missing request parameters",
86
+ )
67
87
  parser.add_argument("--service-token")
68
88
  parser.add_argument(
69
89
  "--compute-url",
@@ -139,10 +159,18 @@ def render_systemd_unit(
139
159
  repository: Path,
140
160
  config_path: Path,
141
161
  service_name: str,
162
+ gpu_ids: list[int] | None = None,
142
163
  ) -> str:
143
164
  root = repository.resolve()
165
+ working_directory = root / "jobs"
144
166
  launcher = root / ".venv" / "bin" / "algorithm-plugin"
145
167
  config = config_path.resolve()
168
+ environment = ["Environment=PYTHONUNBUFFERED=1"]
169
+ if gpu_ids:
170
+ environment.append(
171
+ "Environment=CUDA_VISIBLE_DEVICES="
172
+ + ",".join(str(gpu_id) for gpu_id in gpu_ids)
173
+ )
146
174
  return "\n".join(
147
175
  (
148
176
  "[Unit]",
@@ -153,12 +181,13 @@ def render_systemd_unit(
153
181
  "[Service]",
154
182
  "Type=simple",
155
183
  "User=root",
156
- f"WorkingDirectory={_escape_systemd_path(root)}",
184
+ f"WorkingDirectory={_escape_systemd_path(working_directory)}",
157
185
  (
158
186
  f"ExecStart={_quote_systemd_path(launcher)} "
159
187
  f"serve --config {_quote_systemd_path(config)}"
160
188
  ),
161
- "Environment=PYTHONUNBUFFERED=1",
189
+ *environment,
190
+ "LimitNOFILE=524288",
162
191
  "Restart=on-failure",
163
192
  "RestartSec=5s",
164
193
  "",
@@ -376,6 +405,11 @@ def generate_config(
376
405
  args: argparse.Namespace,
377
406
  repository: Path,
378
407
  ) -> DeploymentConfig:
408
+ max_concurrency = (
409
+ args.max_concurrency
410
+ if args.max_concurrency is not None
411
+ else max(len(args.gpu_ids), 1)
412
+ )
379
413
  config = DeploymentConfig.generate(
380
414
  output=args.config_output,
381
415
  root=repository,
@@ -383,12 +417,15 @@ def generate_config(
383
417
  host=args.host,
384
418
  port=args.port,
385
419
  webui=args.webui,
386
- max_concurrency=args.max_concurrency,
420
+ max_concurrency=max_concurrency,
387
421
  scratch_dir=args.scratch_dir,
422
+ local_scratch=args.local_scratch,
423
+ local_output=args.local_output,
388
424
  token=args.service_token,
389
425
  registration=registration_from_args(args, repository),
390
426
  gpu_ids=args.gpu_ids,
391
427
  environment=dict(args.env),
428
+ default_parameters=args.default_parameters,
392
429
  )
393
430
  config.save()
394
431
  return config
@@ -407,6 +444,7 @@ def execute(args: argparse.Namespace) -> int:
407
444
  repository=repository,
408
445
  config_path=config_path,
409
446
  service_name=service_name,
447
+ gpu_ids=args.gpu_ids,
410
448
  )
411
449
 
412
450
  inspect_systemd_unit(
@@ -414,6 +452,7 @@ def execute(args: argparse.Namespace) -> int:
414
452
  content=unit,
415
453
  force=args.force,
416
454
  )
455
+ (repository / "jobs").mkdir(exist_ok=True)
417
456
  config = generate_config(args, repository)
418
457
  unit_path, changed = write_systemd_unit(
419
458
  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,15 +79,22 @@ 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
84
+ local_scratch = False
85
+ local_output = False
81
86
  if args.config:
82
- config = DeploymentConfig.load(args.config)
83
- config.save()
87
+ # Apply the process mask before refreshing/loading the algorithm. A
88
+ # repository module may import CUDA during module import.
89
+ config = DeploymentConfig.load(args.config, refresh=False)
84
90
  service = config.payload["service"]
85
91
  args.algorithm = config.algorithm_reference
86
92
  args.host = str(service["host"])
87
93
  args.port = int(service["port"])
88
94
  args.max_concurrent_executions = int(service["maxConcurrency"])
89
95
  args.scratch_dir = service.get("scratchDir")
96
+ local_scratch = bool(service.get("localScratch", False))
97
+ local_output = bool(service.get("localOutput", False))
90
98
  args.gpu_ids = list(service.get("gpuIds", []))
91
99
  args.webui = bool(service.get("webui"))
92
100
  values = config.process_environment()
@@ -96,6 +104,11 @@ def execute(args: argparse.Namespace) -> int:
96
104
  elif not args.algorithm:
97
105
  raise ValueError("algorithm is required unless --config is provided")
98
106
 
107
+ args.gpu_ids = apply_gpu_isolation(args.gpu_ids)
108
+ if config is not None:
109
+ config.refresh()
110
+ config.save()
111
+
99
112
  algorithm_reference = str(args.algorithm)
100
113
  algorithm = load_algorithm(algorithm_reference)
101
114
 
@@ -105,14 +118,21 @@ def execute(args: argparse.Namespace) -> int:
105
118
  release = ReleaseManifest.discover(algorithm, start=Path.cwd(), required=False)
106
119
  manager = ExecutionManager(
107
120
  AlgorithmRunner(algorithm),
121
+ default_parameters=(
122
+ service.get("defaultParameters", {})
123
+ if service is not None else None
124
+ ),
108
125
  max_concurrent_executions=args.max_concurrent_executions,
109
126
  scratch_dir=args.scratch_dir,
127
+ local_scratch=local_scratch,
128
+ local_output=local_output,
110
129
  gpu_ids=args.gpu_ids,
111
130
  runner_factory=create_runner,
112
131
  max_attempts=args.max_attempts,
113
132
  retry_seconds=args.retry_seconds,
114
133
  retry_jitter_ratio=args.retry_jitter_ratio,
115
134
  release_manifest=release,
135
+ repository=(config.repository if config is not None else Path.cwd()),
116
136
  )
117
137
  registration = PluginRegistrationAgent.from_env(manager.manifest, manager.heartbeat)
118
138
  if registration is not None and release is None:
@@ -145,6 +145,9 @@ 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,
149
+ local_scratch: bool = False,
150
+ local_output: bool = False,
148
151
  ) -> "DeploymentConfig":
149
152
  repository = repository_root(root)
150
153
  _make_repository_importable(repository)
@@ -188,8 +191,11 @@ class DeploymentConfig:
188
191
  "webui": webui,
189
192
  "maxConcurrency": max_concurrency,
190
193
  "scratchDir": _absolute(scratch_dir, repository),
194
+ "localScratch": local_scratch,
195
+ "localOutput": local_output,
191
196
  "gpuIds": configured_gpu_ids,
192
197
  "token": token or None,
198
+ "defaultParameters": dict(default_parameters or {}),
193
199
  },
194
200
  "registration": registration,
195
201
  "environment": dict(sorted((environment or {}).items())),
@@ -266,6 +272,9 @@ class DeploymentConfig:
266
272
  raise RuntimeError("service port must be in [1, 65535]")
267
273
  if concurrency < 1:
268
274
  raise RuntimeError("service maxConcurrency must be positive")
275
+ for name in ("localScratch", "localOutput"):
276
+ if not isinstance(service.get(name, False), bool):
277
+ raise RuntimeError(f"service {name} must be a boolean")
269
278
  gpu_ids = service.get("gpuIds")
270
279
  if not isinstance(gpu_ids, list) or any(
271
280
  isinstance(gpu_id, bool) or not isinstance(gpu_id, int) or gpu_id < 0
@@ -273,6 +282,16 @@ class DeploymentConfig:
273
282
  ) or len(gpu_ids) != len(set(gpu_ids)):
274
283
  raise RuntimeError("service gpuIds must be unique non-negative integers")
275
284
 
285
+ default_parameters = service.get("defaultParameters", {})
286
+ if not isinstance(default_parameters, dict):
287
+ raise RuntimeError("defaultParameters must be an object")
288
+ if any(not isinstance(key, str) for key in default_parameters):
289
+ raise RuntimeError("defaultParameters keys must be strings")
290
+ try:
291
+ json.dumps(default_parameters)
292
+ except (TypeError, ValueError) as exc:
293
+ raise RuntimeError("defaultParameters must be JSON serializable") from exc
294
+
276
295
  def _validate_registration(self) -> None:
277
296
  registration = self.payload.get("registration")
278
297
  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,125 @@
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
+ _TIMESTAMP_GLOB = "[0-9]" * 14
14
+ _FILE_LOCKS_GUARD = threading.Lock()
15
+ _FILE_LOCKS: dict[Path, threading.RLock] = {}
16
+
17
+
18
+ def _safe_name(value: str) -> str:
19
+ cleaned = _SAFE_NAME.sub("_", str(value).strip()).strip("._")
20
+ return cleaned or "job"
21
+
22
+
23
+ def _file_lock(path: Path) -> threading.RLock:
24
+ with _FILE_LOCKS_GUARD:
25
+ return _FILE_LOCKS.setdefault(path, threading.RLock())
26
+
27
+
28
+ def _timestamp_prefix() -> str:
29
+ return utc_now().replace("-", "").replace(":", "").replace("T", "")[:14]
30
+
31
+
32
+ def _job_log_path(directory: Path, job_id: str) -> Path:
33
+ safe_job_id = _safe_name(job_id)
34
+ pattern = f"{_TIMESTAMP_GLOB}-{safe_job_id}.log"
35
+ with _FILE_LOCKS_GUARD:
36
+ existing = sorted(directory.glob(pattern))
37
+ if existing:
38
+ return existing[0]
39
+ timestamp = _timestamp_prefix()
40
+ path = directory / f"{timestamp}-{safe_job_id}.log"
41
+ path.touch(exist_ok=True)
42
+ return path
43
+
44
+
45
+ class ExecutionLog:
46
+ """Per-job, line-buffered log sink attached to context.logger."""
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ log_dir: str | Path,
52
+ job_id: str,
53
+ execution_id: str,
54
+ logger: logging.Logger,
55
+ task_id: str | None = None,
56
+ ) -> None:
57
+ resolved_task_id = task_id or execution_id
58
+ self._event_context = {
59
+ "jobId": job_id,
60
+ "taskId": resolved_task_id,
61
+ "executionId": execution_id,
62
+ "job_id": job_id,
63
+ "task_id": resolved_task_id,
64
+ "execution_id": execution_id,
65
+ }
66
+ directory = Path(log_dir).expanduser()
67
+ directory.mkdir(parents=True, exist_ok=True)
68
+ path = _job_log_path(directory, job_id)
69
+ self.path = path
70
+ self._stream = path.open("a", encoding="utf-8", buffering=1)
71
+ self._lock = _file_lock(path)
72
+ self._handler = _FlushFileHandler(self._stream, self._lock)
73
+ self._handler.setFormatter(
74
+ logging.Formatter(
75
+ f"%(asctime)s %(levelname)s [executionId={execution_id}] %(message)s"
76
+ )
77
+ )
78
+ self._handler.setLevel(logging.DEBUG)
79
+ self.logger = logger
80
+ self.logger.setLevel(logging.DEBUG)
81
+ self.logger.addHandler(self._handler)
82
+ self._closed = False
83
+
84
+ def write_event(self, event: str, **values: Any) -> None:
85
+ payload = {
86
+ "event": event,
87
+ "timestamp": utc_now(),
88
+ **self._event_context,
89
+ **values,
90
+ }
91
+ with self._lock:
92
+ if self._closed:
93
+ return
94
+ self._stream.write(json.dumps(payload, ensure_ascii=False, default=str) + "\n")
95
+ self._stream.flush()
96
+
97
+ def close(self) -> None:
98
+ with self._lock:
99
+ if self._closed:
100
+ return
101
+ self._closed = True
102
+ self.logger.removeHandler(self._handler)
103
+ self._handler.close()
104
+ self._stream.close()
105
+
106
+
107
+ class _FlushFileHandler(logging.Handler):
108
+ def __init__(self, stream: Any, lock: threading.RLock) -> None:
109
+ super().__init__()
110
+ self.stream = stream
111
+ self._lock = lock
112
+
113
+ def emit(self, record: logging.LogRecord) -> None:
114
+ try:
115
+ with self._lock:
116
+ self.stream.write(self.format(record) + "\n")
117
+ self.stream.flush()
118
+ except Exception:
119
+ self.handleError(record)
120
+
121
+ def close(self) -> None:
122
+ try:
123
+ self.stream.flush()
124
+ finally:
125
+ super().close()
@@ -6,16 +6,19 @@ import logging
6
6
  import random
7
7
  import threading
8
8
  import time
9
+ import traceback
9
10
  import uuid
10
11
  from concurrent.futures import Future, ThreadPoolExecutor
11
12
  from contextlib import asynccontextmanager
12
13
  from dataclasses import dataclass, field, replace
14
+ from pathlib import Path
13
15
  from typing import Any, Callable
14
16
 
15
17
  from .context import ExecutionContext, ProgressSnapshot, utc_now
16
18
  from .errors import (ExecutionCancelled, ExecutionNotFinished,
17
19
  ExecutionNotFound, IdempotencyConflict)
18
20
  from .models import AlgorithmRequest, AlgorithmResult
21
+ from .log_manager import ExecutionLog
19
22
  from .release import ReleaseManifest
20
23
  from .runner import AlgorithmRunner
21
24
  from .webui_app import announce_webui, mount_webui
@@ -34,6 +37,8 @@ logger = logging.getLogger(__name__)
34
37
  class ExecutionRecord:
35
38
  execution_id: str
36
39
  request: AlgorithmRequest
40
+ job_id: str
41
+ task_id: str
37
42
  accepted_at: str
38
43
  state: str = "accepted"
39
44
  started_at: str | None = None
@@ -41,6 +46,7 @@ class ExecutionRecord:
41
46
  result: AlgorithmResult | None = None
42
47
  error: str | None = None
43
48
  context: ExecutionContext | None = None
49
+ log: ExecutionLog | None = None
44
50
  future: Future[None] | None = None
45
51
  attempt: int = 0
46
52
  max_attempts: int = 1
@@ -113,8 +119,13 @@ class ExecutionManager:
113
119
  self,
114
120
  runner: AlgorithmRunner,
115
121
  *,
122
+ default_parameters: dict[str, Any] | None = None,
116
123
  max_concurrent_executions: int = 1,
117
124
  scratch_dir: str | None = None,
125
+ repository: str | Path | None = None,
126
+ log_dir: str | Path | None = None,
127
+ local_scratch: bool = False,
128
+ local_output: bool = False,
118
129
  gpu_ids: list[int] | None = None,
119
130
  runner_factory: RunnerFactory | None = None,
120
131
  max_attempts: int = MAX_ATTEMPTS,
@@ -130,6 +141,17 @@ class ExecutionManager:
130
141
  raise ValueError("retry_seconds must be positive")
131
142
  if not 0 <= retry_jitter_ratio <= 1:
132
143
  raise ValueError("retry_jitter_ratio must be in [0, 1]")
144
+ if not isinstance(local_scratch, bool) or not isinstance(
145
+ local_output, bool
146
+ ):
147
+ raise ValueError("local_scratch and local_output must be booleans")
148
+ repository_path = (
149
+ Path(repository).expanduser().resolve()
150
+ if repository is not None
151
+ else None
152
+ )
153
+ if (local_scratch or local_output) and repository_path is None:
154
+ raise ValueError("repository is required for local scratch or output")
133
155
  configured_gpu_ids = None if gpu_ids is None else list(gpu_ids)
134
156
  if configured_gpu_ids is not None and (
135
157
  any(
@@ -144,7 +166,24 @@ class ExecutionManager:
144
166
  self._runner_factory = runner_factory or (
145
167
  lambda: AlgorithmRunner(algorithm_class())
146
168
  )
169
+ if default_parameters is not None and not isinstance(default_parameters, dict):
170
+ raise ValueError("default_parameters must be a JSON object")
171
+ self.default_parameters = dict(default_parameters or {})
147
172
  self.scratch_dir = scratch_dir
173
+ self._local_scratch_root = (
174
+ repository_path / ".scratch" if local_scratch else None
175
+ )
176
+ self._local_output_root = (
177
+ repository_path / ".output" if local_output else None
178
+ )
179
+ for local_root in (self._local_scratch_root, self._local_output_root):
180
+ if local_root is not None:
181
+ local_root.mkdir(parents=True, exist_ok=True)
182
+ self.log_dir = (
183
+ Path(log_dir).expanduser()
184
+ if log_dir is not None
185
+ else (repository_path / "logs" if repository_path is not None else None)
186
+ )
148
187
  self.gpu_ids = configured_gpu_ids
149
188
  self.max_concurrent_executions = max_concurrent_executions
150
189
  self.max_attempts = max_attempts
@@ -175,14 +214,61 @@ class ExecutionManager:
175
214
  self.runner.start()
176
215
  self._started = True
177
216
 
217
+ def _apply_local_paths(
218
+ self,
219
+ request: AlgorithmRequest,
220
+ job_id: str,
221
+ ) -> AlgorithmRequest:
222
+ workspace = dict(request.workspace or {})
223
+ inputs = request.inputs
224
+ if self._local_scratch_root is not None:
225
+ workspace["scratchRoot"] = str(self._local_scratch_root)
226
+ if self._local_output_root is not None:
227
+ workspace["outputRoot"] = str(self._local_output_root)
228
+ job_output = self._job_output_dir(job_id)
229
+ if len(inputs) == 1:
230
+ output_paths = [job_output]
231
+ else:
232
+ output_paths = [
233
+ job_output / f"{index}-{Path(item.output).name or 'output'}"
234
+ for index, item in enumerate(inputs)
235
+ ]
236
+ for output_path in output_paths:
237
+ output_path.mkdir(parents=True, exist_ok=True)
238
+ inputs = [
239
+ replace(item, output=str(output_path))
240
+ for item, output_path in zip(inputs, output_paths)
241
+ ]
242
+ return replace(request, inputs=inputs, workspace=workspace or None)
243
+
244
+ def _job_output_dir(self, job_id: str) -> Path:
245
+ assert self._local_output_root is not None
246
+ if Path(job_id).name != job_id or job_id in {"", ".", ".."}:
247
+ raise ValueError(f"job ID is unsafe for local output: {job_id!r}")
248
+ output = (self._local_output_root / job_id).resolve()
249
+ if (
250
+ output == self._local_output_root
251
+ or self._local_output_root not in output.parents
252
+ ):
253
+ raise ValueError(f"job output escapes local root: {job_id!r}")
254
+ output.mkdir(parents=True, exist_ok=True)
255
+ return output
256
+
178
257
  def submit(
179
258
  self,
180
259
  request: AlgorithmRequest,
181
260
  *,
182
261
  idempotency_key: str | None = None,
262
+ job_id: str | None = None,
263
+ task_id: str | None = None,
183
264
  ) -> dict[str, Any]:
184
265
  if self.gpu_ids is not None:
185
266
  request = replace(request, gpu_ids=self.gpu_ids)
267
+ if self.default_parameters:
268
+ request = replace(
269
+ request,
270
+ parameters={**self.default_parameters, **request.parameters},
271
+ )
186
272
  self.start()
187
273
  canonical_request = json.dumps(
188
274
  request.to_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
@@ -201,22 +287,51 @@ class ExecutionManager:
201
287
  "acceptedAt": record.accepted_at,
202
288
  }
203
289
  execution_id = f"exec-{uuid.uuid4().hex}"
290
+ resolved_job_id = str(job_id or execution_id)
291
+ resolved_task_id = str(task_id or execution_id)
292
+ request = self._apply_local_paths(request, resolved_job_id)
204
293
  record = ExecutionRecord(
205
294
  execution_id=execution_id,
206
295
  request=request,
296
+ job_id=resolved_job_id,
297
+ task_id=resolved_task_id,
207
298
  accepted_at=utc_now(),
208
299
  max_attempts=self.max_attempts,
209
300
  )
210
301
  record.context = ExecutionContext(
211
302
  execution_id,
212
303
  [item.input_dataset for item in request.inputs],
213
- scratch_dir=(
214
- record.request.workspace["scratchRoot"]
215
- if record.request.workspace is not None
216
- else self.scratch_dir
304
+ scratch_dir=(record.request.workspace or {}).get(
305
+ "scratchRoot",
306
+ self.scratch_dir,
217
307
  ),
218
308
  progress_callback=record.add_progress,
219
309
  )
310
+ if self.log_dir is not None:
311
+ record.log = ExecutionLog(
312
+ log_dir=self.log_dir,
313
+ job_id=record.job_id,
314
+ task_id=record.task_id,
315
+ execution_id=record.execution_id,
316
+ logger=record.context.logger,
317
+ )
318
+ workspace = record.request.workspace or {}
319
+ record.log.write_event(
320
+ "request",
321
+ jobId=record.job_id,
322
+ taskId=record.task_id,
323
+ job_id=record.job_id,
324
+ task_id=record.task_id,
325
+ executionId=record.execution_id,
326
+ execution_id=record.execution_id,
327
+ inputs=[item.to_dict() for item in record.request.inputs],
328
+ inputRoot=workspace.get("inputRoot"),
329
+ outputRoot=workspace.get("outputRoot"),
330
+ scratchRoot=workspace.get("scratchRoot") or self.scratch_dir,
331
+ merge=record.request.merge,
332
+ parameters=record.request.parameters,
333
+ gpuIds=record.request.gpu_ids,
334
+ )
220
335
  with self._lock:
221
336
  if self._closed:
222
337
  raise RuntimeError("execution manager is closed")
@@ -328,6 +443,9 @@ class ExecutionManager:
328
443
  cancelled_before_start = bool(record.future and record.future.cancel())
329
444
  if cancelled_before_start:
330
445
  record.set_state("cancelled", error=reason)
446
+ if record.log is not None:
447
+ record.log.write_event("result", state="cancelled", error=reason)
448
+ record.log.close()
331
449
  return {
332
450
  "executionId": execution_id,
333
451
  "state": record.state,
@@ -349,6 +467,9 @@ class ExecutionManager:
349
467
  record.context.cancel("service shutdown")
350
468
  if record.future and record.future.cancel():
351
469
  record.set_state("cancelled", error="service shutdown")
470
+ if record.log is not None:
471
+ record.log.write_event("result", state="cancelled", error="service shutdown")
472
+ record.log.close()
352
473
  self._executor.shutdown(wait=True, cancel_futures=True)
353
474
  self.runner.close()
354
475
 
@@ -362,17 +483,34 @@ class ExecutionManager:
362
483
  def _run(self, record: ExecutionRecord) -> None:
363
484
  record.set_state("running")
364
485
  assert record.context is not None
486
+ if record.log is not None:
487
+ record.log.write_event("started", jobId=record.job_id, taskId=record.task_id)
365
488
  try:
366
489
  result = self._run_with_restarts(record)
367
490
  except ExecutionCancelled as exc:
368
491
  record.context.mark_unfinished("cancelled", str(exc))
369
492
  record.set_state("cancelled", error=str(exc))
493
+ if record.log is not None:
494
+ record.log.write_event("result", state="cancelled", error=str(exc))
370
495
  except Exception as exc:
371
496
  error = f"{type(exc).__name__}: {exc}"
497
+ stack_trace = traceback.format_exc()
372
498
  record.context.mark_unfinished("failed", error)
373
499
  record.set_state("failed", error=error)
500
+ if record.log is not None:
501
+ record.log.write_event(
502
+ "result",
503
+ state="failed",
504
+ error=error,
505
+ traceback=stack_trace,
506
+ )
374
507
  else:
375
508
  record.set_state(result.status, result=result)
509
+ if record.log is not None:
510
+ record.log.write_event("result", state=record.state, result=result.to_dict())
511
+ finally:
512
+ if record.log is not None:
513
+ record.log.close()
376
514
 
377
515
  def _run_with_restarts(self, record: ExecutionRecord) -> AlgorithmResult:
378
516
  assert record.context is not None
@@ -398,7 +536,7 @@ class ExecutionManager:
398
536
  self.retry_seconds * (1 - self.retry_jitter_ratio),
399
537
  self.retry_seconds * (1 + self.retry_jitter_ratio),
400
538
  )
401
- logger.warning(
539
+ record.context.logger.warning(
402
540
  "algorithm execution %s failed on attempt %d/%d; "
403
541
  "releasing and restarting algorithm in %.3f seconds: %s: %s",
404
542
  record.execution_id,
@@ -407,16 +545,18 @@ class ExecutionManager:
407
545
  delay,
408
546
  type(exc).__name__,
409
547
  exc,
548
+ exc_info=True,
410
549
  )
411
550
  else:
412
551
  delay = 0.0
413
- logger.error(
552
+ record.context.logger.error(
414
553
  "algorithm execution %s exhausted %d attempts; "
415
554
  "releasing and restarting algorithm for future requests: %s: %s",
416
555
  record.execution_id,
417
556
  self.max_attempts,
418
557
  type(exc).__name__,
419
558
  exc,
559
+ exc_info=True,
420
560
  )
421
561
  finally:
422
562
  self._release_runner()
@@ -585,7 +725,12 @@ def create_app(
585
725
  async def create_execution(request: Request) -> Any:
586
726
  try:
587
727
  body = await request.json()
588
- execution_request = AlgorithmRequest.from_dict(body)
728
+ if not isinstance(body, dict):
729
+ raise ValueError("request body must be a mapping")
730
+ job_id = next((body.get(name) for name in ("jobId", "job_id") if body.get(name)), None)
731
+ task_id = next((body.get(name) for name in ("taskId", "task_id") if body.get(name)), None)
732
+ request_body = {key: value for key, value in body.items() if key not in {"jobId", "job_id", "taskId", "task_id"}}
733
+ execution_request = AlgorithmRequest.from_dict(request_body)
589
734
  if execution_request.workspace is None:
590
735
  raise ValueError("workspace is required")
591
736
  except Exception as exc:
@@ -593,9 +738,14 @@ def create_app(
593
738
  status_code=422,
594
739
  content={"error": f"{type(exc).__name__}: {exc}"},
595
740
  )
741
+ idempotency_key = request.headers.get("Idempotency-Key")
742
+ 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)
743
+ 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
744
  return manager.submit(
597
745
  execution_request,
598
- idempotency_key=request.headers.get("Idempotency-Key"),
746
+ idempotency_key=idempotency_key,
747
+ job_id=job_id or idempotency_key,
748
+ task_id=task_id,
599
749
  )
600
750
 
601
751
  @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.4
4
4
  Summary: Protocol-independent runtime SDK for dataset algorithms
5
5
  Author: Ling Robotics
6
6
  License: Proprietary
@@ -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=yv6UCMtbX1pglfq7H7N2xI4_-bM-YLSPXFST5m_pW-g,13931
5
+ algorithm_plugin_sdk/deployment.py,sha256=5WlZfML9ymWrAFsmdX-bBHTP2kuVY4WO5wKHs35Dkd4,14936
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=fmqSivxi-BCOWs4Qomms9nHZC7MRl5NUVuRiTL0J12Q,3723
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=HQLDW5nlGGXJNZ0KMFU4I1rPzWHMf_LlE-tsY42PHnc,31045
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=fr_xDLh82YKV8Csuxi5M2z4dPpXWv9j7SZUHa1n14dA,12496
17
+ algorithm_plugin_sdk/cli_impl/configure.py,sha256=hO9vwFfZ4yqeQUyYc3heE1MDLK1lppN69OoF2zeVVaM,13761
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=1Ma08MHMjKk9wKO8tGdymVvedcGC-jaKdaUCVBTPMro,5187
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.1.dist-info/METADATA,sha256=m3-0HP49-i6sFKAgZjcsWLMGdUdVL-jLUag4siJ9i3Q,2317
27
- ls_algorithm_plugin_sdk-0.3.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
28
- ls_algorithm_plugin_sdk-0.3.1.dist-info/entry_points.txt,sha256=TU0R_TxuB5OuOiZ5BHOEmUPtHvN3v8ARUW8d3PztHcE,67
29
- ls_algorithm_plugin_sdk-0.3.1.dist-info/top_level.txt,sha256=8lsgxZ8HJGLlzJ5Rt8CiVzw9Ccme286i3akbmeMjBos,21
30
- ls_algorithm_plugin_sdk-0.3.1.dist-info/RECORD,,
28
+ ls_algorithm_plugin_sdk-0.3.4.dist-info/METADATA,sha256=S21Rk6HWDt6q5H94F_pj8DpUtY-bKCicc7Vlmq1zWxA,2317
29
+ ls_algorithm_plugin_sdk-0.3.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
30
+ ls_algorithm_plugin_sdk-0.3.4.dist-info/entry_points.txt,sha256=TU0R_TxuB5OuOiZ5BHOEmUPtHvN3v8ARUW8d3PztHcE,67
31
+ ls_algorithm_plugin_sdk-0.3.4.dist-info/top_level.txt,sha256=8lsgxZ8HJGLlzJ5Rt8CiVzw9Ccme286i3akbmeMjBos,21
32
+ ls_algorithm_plugin_sdk-0.3.4.dist-info/RECORD,,