ls-algorithm-plugin-sdk 0.3.1__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.
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/PKG-INFO +1 -1
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/pyproject.toml +1 -1
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli_impl/configure.py +44 -5
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli_impl/run.py +5 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli_impl/serve.py +22 -2
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/deployment.py +19 -0
- ls_algorithm_plugin_sdk-0.3.4/src/algorithm_plugin_sdk/gpu_isolation.py +27 -0
- ls_algorithm_plugin_sdk-0.3.4/src/algorithm_plugin_sdk/log_manager.py +125 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/service.py +158 -8
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/PKG-INFO +1 -1
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/SOURCES.txt +4 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_configure.py +42 -2
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_deployment.py +9 -0
- ls_algorithm_plugin_sdk-0.3.4/tests/test_gpu_isolation.py +38 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_runner.py +26 -0
- ls_algorithm_plugin_sdk-0.3.4/tests/test_serve.py +73 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_service.py +192 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/README.md +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/setup.cfg +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/__init__.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/algorithm.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli_impl/__init__.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/cli_impl/parsing.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/context.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/errors.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/examples/__init__.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/examples/example_algorithm.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/examples/simulated_algorithm.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/loader.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/models.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/registration.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/release.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/runner.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/webui/__init__.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/webui/app.css +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/webui/app.js +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/webui/index.html +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/algorithm_plugin_sdk/webui_app.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/dependency_links.txt +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/entry_points.txt +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/requires.txt +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/src/ls_algorithm_plugin_sdk.egg-info/top_level.txt +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_cli.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_models.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_registration.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_release.py +0 -0
- {ls_algorithm_plugin_sdk-0.3.1 → ls_algorithm_plugin_sdk-0.3.4}/tests/test_sdk_automation.py +0 -0
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
83
|
-
|
|
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()
|