ls-algorithm-plugin-sdk 0.3.3__tar.gz → 0.3.4__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 (49) hide show
  1. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/PKG-INFO +1 -1
  2. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/pyproject.toml +1 -1
  3. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli_impl/configure.py +12 -0
  4. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli_impl/serve.py +6 -0
  5. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/deployment.py +7 -0
  6. ls_algorithm_plugin_sdk-0.3.4/src/algorithm_plugin_sdk/log_manager.py +125 -0
  7. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/service.py +80 -8
  8. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/PKG-INFO +1 -1
  9. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/SOURCES.txt +1 -0
  10. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_configure.py +8 -0
  11. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_deployment.py +4 -0
  12. ls_algorithm_plugin_sdk-0.3.4/tests/test_serve.py +73 -0
  13. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_service.py +172 -0
  14. ls_algorithm_plugin_sdk-0.3.3/src/algorithm_plugin_sdk/log_manager.py +0 -76
  15. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/README.md +0 -0
  16. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/setup.cfg +0 -0
  17. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/__init__.py +0 -0
  18. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/algorithm.py +0 -0
  19. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli.py +0 -0
  20. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli_impl/__init__.py +0 -0
  21. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli_impl/parsing.py +0 -0
  22. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli_impl/run.py +0 -0
  23. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/context.py +0 -0
  24. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/errors.py +0 -0
  25. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/examples/__init__.py +0 -0
  26. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/examples/example_algorithm.py +0 -0
  27. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/examples/simulated_algorithm.py +0 -0
  28. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/gpu_isolation.py +0 -0
  29. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/loader.py +0 -0
  30. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/models.py +0 -0
  31. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/registration.py +0 -0
  32. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/release.py +0 -0
  33. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/runner.py +0 -0
  34. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/webui/__init__.py +0 -0
  35. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/webui/app.css +0 -0
  36. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/webui/app.js +0 -0
  37. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/webui/index.html +0 -0
  38. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/webui_app.py +0 -0
  39. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/dependency_links.txt +0 -0
  40. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/entry_points.txt +0 -0
  41. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/requires.txt +0 -0
  42. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/top_level.txt +0 -0
  43. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_cli.py +0 -0
  44. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_gpu_isolation.py +0 -0
  45. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_models.py +0 -0
  46. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_registration.py +0 -0
  47. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_release.py +0 -0
  48. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_runner.py +0 -0
  49. {ls_algorithm_plugin_sdk-0.3.3 → ls_algorithm_plugin_sdk-0.3.4}/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.3
3
+ Version: 0.3.4
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.3"
7
+ version = "0.3.4"
8
8
  description = "Protocol-independent runtime SDK for dataset algorithms"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -62,6 +62,16 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
62
62
  help="maximum concurrent executions (default: GPU count, or 1)",
63
63
  )
64
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
+ )
65
75
  parser.add_argument(
66
76
  "--gpu-ids",
67
77
  type=gpu_ids,
@@ -409,6 +419,8 @@ def generate_config(
409
419
  webui=args.webui,
410
420
  max_concurrency=max_concurrency,
411
421
  scratch_dir=args.scratch_dir,
422
+ local_scratch=args.local_scratch,
423
+ local_output=args.local_output,
412
424
  token=args.service_token,
413
425
  registration=registration_from_args(args, repository),
414
426
  gpu_ids=args.gpu_ids,
@@ -81,6 +81,8 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
81
81
  def execute(args: argparse.Namespace) -> int:
82
82
  config: DeploymentConfig | None = None
83
83
  service: dict[str, object] | None = None
84
+ local_scratch = False
85
+ local_output = False
84
86
  if args.config:
85
87
  # Apply the process mask before refreshing/loading the algorithm. A
86
88
  # repository module may import CUDA during module import.
@@ -91,6 +93,8 @@ def execute(args: argparse.Namespace) -> int:
91
93
  args.port = int(service["port"])
92
94
  args.max_concurrent_executions = int(service["maxConcurrency"])
93
95
  args.scratch_dir = service.get("scratchDir")
96
+ local_scratch = bool(service.get("localScratch", False))
97
+ local_output = bool(service.get("localOutput", False))
94
98
  args.gpu_ids = list(service.get("gpuIds", []))
95
99
  args.webui = bool(service.get("webui"))
96
100
  values = config.process_environment()
@@ -120,6 +124,8 @@ def execute(args: argparse.Namespace) -> int:
120
124
  ),
121
125
  max_concurrent_executions=args.max_concurrent_executions,
122
126
  scratch_dir=args.scratch_dir,
127
+ local_scratch=local_scratch,
128
+ local_output=local_output,
123
129
  gpu_ids=args.gpu_ids,
124
130
  runner_factory=create_runner,
125
131
  max_attempts=args.max_attempts,
@@ -146,6 +146,8 @@ class DeploymentConfig:
146
146
  gpu_ids: list[int] | None = None,
147
147
  environment: dict[str, str] | None = None,
148
148
  default_parameters: dict[str, Any] | None = None,
149
+ local_scratch: bool = False,
150
+ local_output: bool = False,
149
151
  ) -> "DeploymentConfig":
150
152
  repository = repository_root(root)
151
153
  _make_repository_importable(repository)
@@ -189,6 +191,8 @@ class DeploymentConfig:
189
191
  "webui": webui,
190
192
  "maxConcurrency": max_concurrency,
191
193
  "scratchDir": _absolute(scratch_dir, repository),
194
+ "localScratch": local_scratch,
195
+ "localOutput": local_output,
192
196
  "gpuIds": configured_gpu_ids,
193
197
  "token": token or None,
194
198
  "defaultParameters": dict(default_parameters or {}),
@@ -268,6 +272,9 @@ class DeploymentConfig:
268
272
  raise RuntimeError("service port must be in [1, 65535]")
269
273
  if concurrency < 1:
270
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")
271
278
  gpu_ids = service.get("gpuIds")
272
279
  if not isinstance(gpu_ids, list) or any(
273
280
  isinstance(gpu_id, bool) or not isinstance(gpu_id, int) or gpu_id < 0
@@ -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,6 +6,7 @@ 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
@@ -123,6 +124,8 @@ class ExecutionManager:
123
124
  scratch_dir: str | None = None,
124
125
  repository: str | Path | None = None,
125
126
  log_dir: str | Path | None = None,
127
+ local_scratch: bool = False,
128
+ local_output: bool = False,
126
129
  gpu_ids: list[int] | None = None,
127
130
  runner_factory: RunnerFactory | None = None,
128
131
  max_attempts: int = MAX_ATTEMPTS,
@@ -138,6 +141,17 @@ class ExecutionManager:
138
141
  raise ValueError("retry_seconds must be positive")
139
142
  if not 0 <= retry_jitter_ratio <= 1:
140
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")
141
155
  configured_gpu_ids = None if gpu_ids is None else list(gpu_ids)
142
156
  if configured_gpu_ids is not None and (
143
157
  any(
@@ -156,10 +170,19 @@ class ExecutionManager:
156
170
  raise ValueError("default_parameters must be a JSON object")
157
171
  self.default_parameters = dict(default_parameters or {})
158
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)
159
182
  self.log_dir = (
160
183
  Path(log_dir).expanduser()
161
184
  if log_dir is not None
162
- else (Path(repository).expanduser() / "logs" if repository is not None else None)
185
+ else (repository_path / "logs" if repository_path is not None else None)
163
186
  )
164
187
  self.gpu_ids = configured_gpu_ids
165
188
  self.max_concurrent_executions = max_concurrent_executions
@@ -191,6 +214,46 @@ class ExecutionManager:
191
214
  self.runner.start()
192
215
  self._started = True
193
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
+
194
257
  def submit(
195
258
  self,
196
259
  request: AlgorithmRequest,
@@ -226,6 +289,7 @@ class ExecutionManager:
226
289
  execution_id = f"exec-{uuid.uuid4().hex}"
227
290
  resolved_job_id = str(job_id or execution_id)
228
291
  resolved_task_id = str(task_id or execution_id)
292
+ request = self._apply_local_paths(request, resolved_job_id)
229
293
  record = ExecutionRecord(
230
294
  execution_id=execution_id,
231
295
  request=request,
@@ -237,10 +301,9 @@ class ExecutionManager:
237
301
  record.context = ExecutionContext(
238
302
  execution_id,
239
303
  [item.input_dataset for item in request.inputs],
240
- scratch_dir=(
241
- record.request.workspace["scratchRoot"]
242
- if record.request.workspace is not None
243
- else self.scratch_dir
304
+ scratch_dir=(record.request.workspace or {}).get(
305
+ "scratchRoot",
306
+ self.scratch_dir,
244
307
  ),
245
308
  progress_callback=record.add_progress,
246
309
  )
@@ -248,6 +311,7 @@ class ExecutionManager:
248
311
  record.log = ExecutionLog(
249
312
  log_dir=self.log_dir,
250
313
  job_id=record.job_id,
314
+ task_id=record.task_id,
251
315
  execution_id=record.execution_id,
252
316
  logger=record.context.logger,
253
317
  )
@@ -430,10 +494,16 @@ class ExecutionManager:
430
494
  record.log.write_event("result", state="cancelled", error=str(exc))
431
495
  except Exception as exc:
432
496
  error = f"{type(exc).__name__}: {exc}"
497
+ stack_trace = traceback.format_exc()
433
498
  record.context.mark_unfinished("failed", error)
434
499
  record.set_state("failed", error=error)
435
500
  if record.log is not None:
436
- record.log.write_event("result", state="failed", error=error)
501
+ record.log.write_event(
502
+ "result",
503
+ state="failed",
504
+ error=error,
505
+ traceback=stack_trace,
506
+ )
437
507
  else:
438
508
  record.set_state(result.status, result=result)
439
509
  if record.log is not None:
@@ -466,7 +536,7 @@ class ExecutionManager:
466
536
  self.retry_seconds * (1 - self.retry_jitter_ratio),
467
537
  self.retry_seconds * (1 + self.retry_jitter_ratio),
468
538
  )
469
- logger.warning(
539
+ record.context.logger.warning(
470
540
  "algorithm execution %s failed on attempt %d/%d; "
471
541
  "releasing and restarting algorithm in %.3f seconds: %s: %s",
472
542
  record.execution_id,
@@ -475,16 +545,18 @@ class ExecutionManager:
475
545
  delay,
476
546
  type(exc).__name__,
477
547
  exc,
548
+ exc_info=True,
478
549
  )
479
550
  else:
480
551
  delay = 0.0
481
- logger.error(
552
+ record.context.logger.error(
482
553
  "algorithm execution %s exhausted %d attempts; "
483
554
  "releasing and restarting algorithm for future requests: %s: %s",
484
555
  record.execution_id,
485
556
  self.max_attempts,
486
557
  type(exc).__name__,
487
558
  exc,
559
+ exc_info=True,
488
560
  )
489
561
  finally:
490
562
  self._release_runner()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ls-algorithm-plugin-sdk
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: Protocol-independent runtime SDK for dataset algorithms
5
5
  Author: Ling Robotics
6
6
  License: Proprietary
@@ -42,4 +42,5 @@ tests/test_registration.py
42
42
  tests/test_release.py
43
43
  tests/test_runner.py
44
44
  tests/test_sdk_automation.py
45
+ tests/test_serve.py
45
46
  tests/test_service.py
@@ -140,6 +140,8 @@ class ConfigureTests(unittest.TestCase):
140
140
  "--no-webui",
141
141
  "--max-concurrency", "3",
142
142
  "--scratch-dir", "scratch",
143
+ "--local-scratch",
144
+ "--local-output",
143
145
  "--gpu-ids", "2,0",
144
146
  "--service-token", "secret",
145
147
  "--default-parameters", '{"batchSize": 8, "mode": "fast"}',
@@ -155,6 +157,8 @@ class ConfigureTests(unittest.TestCase):
155
157
  self.assertFalse(args.webui)
156
158
  self.assertEqual(args.max_concurrency, 3)
157
159
  self.assertEqual(args.scratch_dir, "scratch")
160
+ self.assertTrue(args.local_scratch)
161
+ self.assertTrue(args.local_output)
158
162
  self.assertEqual(args.gpu_ids, [2, 0])
159
163
  self.assertEqual(args.service_token, "secret")
160
164
  self.assertEqual(
@@ -186,6 +190,8 @@ class ConfigureTests(unittest.TestCase):
186
190
  "--local",
187
191
  "--config-output", "algorithm-plugin.json",
188
192
  "--gpu-ids", "3,1",
193
+ "--local-scratch",
194
+ "--local-output",
189
195
  "--env", "DEMO_MODE=fast",
190
196
  ]
191
197
  )
@@ -207,6 +213,8 @@ class ConfigureTests(unittest.TestCase):
207
213
  webui=True,
208
214
  max_concurrency=2,
209
215
  scratch_dir=None,
216
+ local_scratch=True,
217
+ local_output=True,
210
218
  token=None,
211
219
  registration=None,
212
220
  gpu_ids=[3, 1],
@@ -66,6 +66,8 @@ class DeploymentConfigTests(unittest.TestCase):
66
66
  webui=True,
67
67
  max_concurrency=1,
68
68
  scratch_dir=None,
69
+ local_scratch=True,
70
+ local_output=True,
69
71
  token=None,
70
72
  registration=None,
71
73
  gpu_ids=[3, 1],
@@ -83,6 +85,8 @@ class DeploymentConfigTests(unittest.TestCase):
83
85
  {"batchSize": 8, "mode": "fast"},
84
86
  )
85
87
  self.assertEqual(loaded.payload["service"]["gpuIds"], [3, 1])
88
+ self.assertTrue(loaded.payload["service"]["localScratch"])
89
+ self.assertTrue(loaded.payload["service"]["localOutput"])
86
90
  manifest_path = root / "release-manifest.json"
87
91
  manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
88
92
  self.assertEqual(
@@ -0,0 +1,73 @@
1
+ import os
2
+ import unittest
3
+ from pathlib import Path
4
+ from unittest.mock import Mock, patch
5
+
6
+ from test_runner import RecordingAlgorithm
7
+
8
+ from algorithm_plugin_sdk.cli import build_parser
9
+ from algorithm_plugin_sdk.cli_impl.serve import execute
10
+
11
+
12
+ class ServeTests(unittest.TestCase):
13
+ def test_config_passes_local_storage_settings_to_manager(self) -> None:
14
+ args = build_parser().parse_args(
15
+ ["serve", "--config", "/srv/demo/algorithm-plugin.json"]
16
+ )
17
+ config = Mock()
18
+ config.algorithm_reference = "demo:Algorithm"
19
+ config.repository = Path("/srv/demo")
20
+ config.payload = {
21
+ "service": {
22
+ "host": "127.0.0.1",
23
+ "port": 9000,
24
+ "maxConcurrency": 2,
25
+ "scratchDir": None,
26
+ "localScratch": True,
27
+ "localOutput": True,
28
+ "gpuIds": [0],
29
+ "webui": False,
30
+ "defaultParameters": {},
31
+ }
32
+ }
33
+ config.process_environment.return_value = dict(os.environ)
34
+ manager = Mock()
35
+
36
+ with (
37
+ patch(
38
+ "algorithm_plugin_sdk.cli_impl.serve.DeploymentConfig.load",
39
+ return_value=config,
40
+ ),
41
+ patch(
42
+ "algorithm_plugin_sdk.cli_impl.serve.apply_gpu_isolation",
43
+ return_value=[0],
44
+ ),
45
+ patch(
46
+ "algorithm_plugin_sdk.cli_impl.serve.load_algorithm",
47
+ return_value=RecordingAlgorithm(),
48
+ ),
49
+ patch(
50
+ "algorithm_plugin_sdk.cli_impl.serve.ReleaseManifest.discover",
51
+ return_value=None,
52
+ ),
53
+ patch(
54
+ "algorithm_plugin_sdk.cli_impl.serve.ExecutionManager",
55
+ return_value=manager,
56
+ ) as manager_class,
57
+ patch(
58
+ "algorithm_plugin_sdk.cli_impl.serve.PluginRegistrationAgent.from_env",
59
+ return_value=None,
60
+ ),
61
+ patch("algorithm_plugin_sdk.cli_impl.serve.run_server"),
62
+ patch("algorithm_plugin_sdk.cli_impl.serve.os.chdir"),
63
+ ):
64
+ self.assertEqual(execute(args), 0)
65
+
66
+ manager_kwargs = manager_class.call_args.kwargs
67
+ self.assertTrue(manager_kwargs["local_scratch"])
68
+ self.assertTrue(manager_kwargs["local_output"])
69
+ self.assertEqual(manager_kwargs["repository"], config.repository)
70
+
71
+
72
+ if __name__ == "__main__":
73
+ unittest.main()
@@ -1,7 +1,10 @@
1
+ import json
1
2
  import time
2
3
  import unittest
3
4
  from contextlib import redirect_stdout
4
5
  from io import StringIO
6
+ from pathlib import Path
7
+ from tempfile import TemporaryDirectory
5
8
  from unittest.mock import patch
6
9
 
7
10
  from test_runner import RecordingAlgorithm
@@ -57,6 +60,85 @@ class ExecutionManagerTests(unittest.TestCase):
57
60
  {"batchSize": 16, "mode": "accurate"},
58
61
  )
59
62
 
63
+ def test_local_storage_overrides_request_paths_per_job(self) -> None:
64
+ with TemporaryDirectory() as repository:
65
+ root = Path(repository)
66
+ algorithm = RecordingAlgorithm()
67
+ manager = ExecutionManager(
68
+ AlgorithmRunner(algorithm),
69
+ repository=root,
70
+ local_scratch=True,
71
+ local_output=True,
72
+ )
73
+ request = AlgorithmRequest(
74
+ inputs=[
75
+ AlgorithmInput(
76
+ input_dataset="dataset-a",
77
+ output="/remote/output/dataset-a",
78
+ )
79
+ ],
80
+ workspace={
81
+ "inputRoot": "/remote/input",
82
+ "outputRoot": "/remote/output",
83
+ "scratchRoot": "/remote/scratch",
84
+ },
85
+ )
86
+
87
+ first = manager.submit(request, job_id="job-local")
88
+ manager._record(first["executionId"]).future.result(timeout=2)
89
+ second = manager.submit(request, job_id="job-local")
90
+ manager._record(second["executionId"]).future.result(timeout=2)
91
+ manager.close()
92
+
93
+ scratch_root = root / ".scratch"
94
+ output_root = root / ".output"
95
+ job_output = output_root / "job-local"
96
+ self.assertTrue(scratch_root.is_dir())
97
+ self.assertTrue(job_output.is_dir())
98
+ for actual in algorithm.calls:
99
+ self.assertEqual(actual.workspace["inputRoot"], "/remote/input")
100
+ self.assertEqual(actual.workspace["scratchRoot"], str(scratch_root))
101
+ self.assertEqual(actual.workspace["outputRoot"], str(output_root))
102
+ self.assertEqual(actual.inputs[0].output, str(job_output))
103
+
104
+ def test_local_output_rejects_unsafe_job_id(self) -> None:
105
+ with TemporaryDirectory() as repository:
106
+ manager = ExecutionManager(
107
+ AlgorithmRunner(RecordingAlgorithm()),
108
+ repository=repository,
109
+ local_output=True,
110
+ )
111
+ with self.assertRaisesRegex(ValueError, "unsafe"):
112
+ manager.submit(self._request(), job_id="../outside")
113
+ manager.close()
114
+
115
+ def test_local_output_keeps_multiple_input_paths_distinct(self) -> None:
116
+ with TemporaryDirectory() as repository:
117
+ root = Path(repository)
118
+ algorithm = RecordingAlgorithm()
119
+ manager = ExecutionManager(
120
+ AlgorithmRunner(algorithm),
121
+ repository=root,
122
+ local_output=True,
123
+ )
124
+ request = AlgorithmRequest(
125
+ inputs=[
126
+ AlgorithmInput(input_dataset="dataset-a", output="remote/a"),
127
+ AlgorithmInput(input_dataset="dataset-b", output="remote/b"),
128
+ ]
129
+ )
130
+
131
+ accepted = manager.submit(request, job_id="job-local")
132
+ manager._record(accepted["executionId"]).future.result(timeout=2)
133
+ manager.close()
134
+
135
+ job_output = root / ".output" / "job-local"
136
+ self.assertEqual(
137
+ [item.output for item in algorithm.calls[0].inputs],
138
+ [str(job_output / "0-a"), str(job_output / "1-b")],
139
+ )
140
+ self.assertTrue(all(path.is_dir() for path in job_output.iterdir()))
141
+
60
142
  def test_runs_the_same_algorithm_contract(self) -> None:
61
143
  manager = ExecutionManager(AlgorithmRunner(RecordingAlgorithm()))
62
144
  accepted = manager.submit(
@@ -149,6 +231,96 @@ class ExecutionManagerTests(unittest.TestCase):
149
231
  manager.close()
150
232
  self.assertTrue(all(algorithm.stopped == 1 for algorithm in algorithms))
151
233
 
234
+ def test_reuses_one_log_file_for_executions_of_same_job(self) -> None:
235
+ with TemporaryDirectory() as log_dir:
236
+ first_manager = ExecutionManager(
237
+ AlgorithmRunner(RecordingAlgorithm()),
238
+ log_dir=log_dir,
239
+ )
240
+ with patch(
241
+ "algorithm_plugin_sdk.log_manager._timestamp_prefix",
242
+ return_value="20260910123456",
243
+ ):
244
+ first = first_manager.submit(
245
+ self._request(),
246
+ job_id="job-shared",
247
+ task_id="task-a",
248
+ )
249
+ first_manager._record(first["executionId"]).future.result(timeout=2)
250
+ first_manager.close()
251
+
252
+ second_manager = ExecutionManager(
253
+ AlgorithmRunner(RecordingAlgorithm()),
254
+ log_dir=log_dir,
255
+ )
256
+ with patch(
257
+ "algorithm_plugin_sdk.log_manager._timestamp_prefix",
258
+ return_value="20260911123456",
259
+ ):
260
+ second = second_manager.submit(
261
+ self._request(),
262
+ job_id="job-shared",
263
+ task_id="task-b",
264
+ )
265
+ second_manager._record(second["executionId"]).future.result(timeout=2)
266
+ second_manager.close()
267
+
268
+ log_files = list(Path(log_dir).glob("*.log"))
269
+ self.assertEqual(
270
+ [path.name for path in log_files],
271
+ ["20260910123456-job-shared.log"],
272
+ )
273
+ events = [
274
+ json.loads(line)
275
+ for line in log_files[0].read_text(encoding="utf-8").splitlines()
276
+ ]
277
+ request_events = [event for event in events if event["event"] == "request"]
278
+ self.assertEqual(
279
+ [event["executionId"] for event in request_events],
280
+ [first["executionId"], second["executionId"]],
281
+ )
282
+ self.assertTrue(
283
+ all(event["jobId"] == "job-shared" for event in events)
284
+ )
285
+ self.assertEqual(
286
+ {event["taskId"] for event in events},
287
+ {"task-a", "task-b"},
288
+ )
289
+
290
+ def test_failed_execution_log_includes_full_traceback(self) -> None:
291
+ def create_runner() -> AlgorithmRunner:
292
+ return AlgorithmRunner(RestartProbeAlgorithm(fail=True))
293
+
294
+ with TemporaryDirectory() as log_dir:
295
+ manager = ExecutionManager(
296
+ create_runner(),
297
+ runner_factory=create_runner,
298
+ max_attempts=1,
299
+ log_dir=log_dir,
300
+ )
301
+ accepted = manager.submit(self._request(), job_id="job-failed")
302
+ manager._record(accepted["executionId"]).future.result(timeout=2)
303
+ manager.close()
304
+
305
+ log_files = list(Path(log_dir).glob("??????????????-job-failed.log"))
306
+ self.assertEqual(len(log_files), 1)
307
+ contents = log_files[0].read_text(encoding="utf-8")
308
+ self.assertIn("Traceback (most recent call last):", contents)
309
+ self.assertIn("RuntimeError: simulated algorithm failure", contents)
310
+ events = [
311
+ json.loads(line)
312
+ for line in contents.splitlines()
313
+ if line.startswith("{")
314
+ ]
315
+ result_event = events[-1]
316
+ self.assertEqual(result_event["event"], "result")
317
+ self.assertEqual(result_event["state"], "failed")
318
+ self.assertEqual(result_event["executionId"], accepted["executionId"])
319
+ self.assertIn(
320
+ "RuntimeError: simulated algorithm failure",
321
+ result_event["traceback"],
322
+ )
323
+
152
324
  def test_next_request_uses_fresh_algorithm_after_attempts_are_exhausted(self) -> None:
153
325
  algorithms = []
154
326
 
@@ -1,76 +0,0 @@
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()