quapp-hpc 0.0.1.dev6__tar.gz → 0.0.1.dev8__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.
- {quapp_hpc-0.0.1.dev6/quapp_hpc.egg-info → quapp_hpc-0.0.1.dev8}/PKG-INFO +1 -1
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/pyproject.toml +33 -33
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8/quapp_hpc.egg-info}/PKG-INFO +1 -1
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc.egg-info/SOURCES.txt +1 -0
- quapp_hpc-0.0.1.dev8/quapp_slurm_hpc/log_writer.py +132 -0
- quapp_hpc-0.0.1.dev8/quapp_slurm_hpc/runner.py +215 -0
- quapp_hpc-0.0.1.dev6/quapp_slurm_hpc/runner.py +0 -112
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/LICENSE +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/README.md +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/component/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/component/backend/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/component/backend/hpc_invocation.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/component/backend/slurm_job_fetching.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/factory/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/factory/hpc_device_factory.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/factory/hpc_handler_factory.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/factory/hpc_provider_factory.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/handler/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/handler/invocation_handler.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/handler/job_fetching_handler.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/model/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/model/device/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/model/device/slurm_device.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/model/provider/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/model/provider/slurm_provider.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc.egg-info/dependency_links.txt +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc.egg-info/requires.txt +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc.egg-info/top_level.txt +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_slurm_hpc/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/setup.cfg +0 -0
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=61.0.0", "wheel==0.45.1"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "quapp-hpc"
|
|
7
|
-
version = "0.0.1.
|
|
8
|
-
description = "Quapp HPC library — Slurm integration for Quapp Platform"
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
authors = [{ name = "CITYNOW Co. Ltd.", email = "corp@citynow.vn" }]
|
|
11
|
-
license = { file = "LICENSE" }
|
|
12
|
-
classifiers = [
|
|
13
|
-
"License :: OSI Approved :: MIT License",
|
|
14
|
-
"Programming Language :: Python",
|
|
15
|
-
"Programming Language :: Python :: 3",
|
|
16
|
-
]
|
|
17
|
-
keywords = ["quapp", "quapp-hpc", "slurm", "hpc"]
|
|
18
|
-
dependencies = [
|
|
19
|
-
"quapp-common==0.0.12.dev12",
|
|
20
|
-
"requests>=2.31.0",
|
|
21
|
-
"boto3>=1.28.0",
|
|
22
|
-
]
|
|
23
|
-
requires-python = ">=3.10,<3.13"
|
|
24
|
-
|
|
25
|
-
[project.optional-dependencies]
|
|
26
|
-
dev = ["black", "bumpver", "isort", "pip-tools", "pytest"]
|
|
27
|
-
|
|
28
|
-
[tool.setuptools.packages.find]
|
|
29
|
-
include = ["quapp_hpc*", "quapp_slurm_hpc*"]
|
|
30
|
-
exclude = ["*.md", "*.yml", "*.yaml", "*.toml", "tests*", ".gitignore"]
|
|
31
|
-
|
|
32
|
-
[project.urls]
|
|
33
|
-
Homepage = "https://quapp.cloud/"
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0.0", "wheel==0.45.1"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quapp-hpc"
|
|
7
|
+
version = "0.0.1.dev8"
|
|
8
|
+
description = "Quapp HPC library — Slurm integration for Quapp Platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{ name = "CITYNOW Co. Ltd.", email = "corp@citynow.vn" }]
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
classifiers = [
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
]
|
|
17
|
+
keywords = ["quapp", "quapp-hpc", "slurm", "hpc"]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"quapp-common==0.0.12.dev12",
|
|
20
|
+
"requests>=2.31.0",
|
|
21
|
+
"boto3>=1.28.0",
|
|
22
|
+
]
|
|
23
|
+
requires-python = ">=3.10,<3.13"
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = ["black", "bumpver", "isort", "pip-tools", "pytest"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
include = ["quapp_hpc*", "quapp_slurm_hpc*"]
|
|
30
|
+
exclude = ["*.md", "*.yml", "*.yaml", "*.toml", "tests*", ".gitignore"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://quapp.cloud/"
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Pushes a job's stdout/stderr to the backend's live log pipeline (PQF-23860) as it runs,
|
|
2
|
+
instead of only via the final output.json snapshot uploaded at the end.
|
|
3
|
+
|
|
4
|
+
No client for this protocol exists anywhere yet (checked qapp-lib / quapp-libs) — this is the
|
|
5
|
+
first one, reverse-engineered directly from the backend's ingest code:
|
|
6
|
+
|
|
7
|
+
JobLogIngestControllerV1 -- POST {base_url}/writers/{writer_id}/open?attempt=N
|
|
8
|
+
-- POST {base_url}/writers/{writer_id}/chunks?attempt=N
|
|
9
|
+
-- POST {base_url}/writers/{writer_id}/close?attempt=N
|
|
10
|
+
JobLogWriteResult -- the response body, wrapped by the backend's global envelope
|
|
11
|
+
as {"data": {"outcome": ..., "endOffset": ..., "expectedSeq": ...}}
|
|
12
|
+
|
|
13
|
+
Every call carries the token the backend issued for this job attempt in the `logIngest` block
|
|
14
|
+
of the invocation input (see JobLogIngestPayloadProvider on the backend). Best-effort only: a
|
|
15
|
+
failure here must never raise into the handler's own execution — the job still has to run to
|
|
16
|
+
completion and still produce output.json exactly as before.
|
|
17
|
+
"""
|
|
18
|
+
import sys
|
|
19
|
+
import uuid
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
_TOKEN_HEADER = "X-Quapp-Log-Token"
|
|
25
|
+
_SEQ_HEADER = "X-Log-Seq"
|
|
26
|
+
_DROPPED_HEADER = "X-Log-Dropped"
|
|
27
|
+
_REQUEST_TIMEOUT_SECONDS = 5
|
|
28
|
+
_STOP_OUTCOMES = {"SEALED", "TOO_LATE", "TRUNCATED"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class JobLogWriter:
|
|
32
|
+
"""A single log writer for one job attempt. Not thread-safe — call from one thread only."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, base_url: str, token: str, attempt: int, headers: Optional[dict] = None):
|
|
35
|
+
self._base_url = base_url.rstrip("/")
|
|
36
|
+
self._token = token
|
|
37
|
+
self._attempt = attempt
|
|
38
|
+
self._extra_headers = headers or {}
|
|
39
|
+
self._writer_id = str(uuid.uuid4())
|
|
40
|
+
self._seq = 0
|
|
41
|
+
self._dropped_bytes = 0
|
|
42
|
+
self._stopped = False
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def stopped(self) -> bool:
|
|
46
|
+
"""True once the backend told us to stop (sealed/too-late/truncated) or a request
|
|
47
|
+
failed — callers should skip further append()/close() calls once this is set, though
|
|
48
|
+
each method is itself a safe no-op either way."""
|
|
49
|
+
return self._stopped
|
|
50
|
+
|
|
51
|
+
def open(self) -> None:
|
|
52
|
+
if self._stopped:
|
|
53
|
+
return
|
|
54
|
+
self._post("open")
|
|
55
|
+
|
|
56
|
+
def append(self, data: bytes) -> None:
|
|
57
|
+
"""Sends one chunk of new output. Silently drops it (and counts it as dropped) once
|
|
58
|
+
streaming has stopped — the handler's own run must never block or fail on this."""
|
|
59
|
+
if not data:
|
|
60
|
+
return
|
|
61
|
+
if self._stopped:
|
|
62
|
+
self._dropped_bytes += len(data)
|
|
63
|
+
return
|
|
64
|
+
outcome, expected_seq = self._post(
|
|
65
|
+
"chunks",
|
|
66
|
+
data=data,
|
|
67
|
+
extra_headers={
|
|
68
|
+
_SEQ_HEADER: str(self._seq),
|
|
69
|
+
_DROPPED_HEADER: str(self._dropped_bytes),
|
|
70
|
+
# The backend's /chunks route is declared `consumes = APPLICATION_OCTET_STREAM`
|
|
71
|
+
# and rejects anything else with 415 — requests does not set this on its own
|
|
72
|
+
# for a raw bytes body.
|
|
73
|
+
"Content-Type": "application/octet-stream",
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
if outcome in ("APPENDED", "DUPLICATE"):
|
|
77
|
+
self._seq += 1
|
|
78
|
+
self._dropped_bytes = 0
|
|
79
|
+
elif outcome == "SEQ_GAP" and expected_seq is not None:
|
|
80
|
+
# The backend tells us exactly what it's missing — resync instead of retrying the
|
|
81
|
+
# same seq forever (which would never succeed and would spin indefinitely).
|
|
82
|
+
self._seq = int(expected_seq)
|
|
83
|
+
self._dropped_bytes += len(data)
|
|
84
|
+
|
|
85
|
+
def close(self, exit_code: int, abandoned: bool = False, discarded: bool = False) -> None:
|
|
86
|
+
"""Closes the writer. Safe to call even if streaming already stopped earlier (e.g. the
|
|
87
|
+
backend sealed it, or a request failed), so a crash can still attempt a clean close
|
|
88
|
+
from a `finally` block without extra branching."""
|
|
89
|
+
self._post(
|
|
90
|
+
"close",
|
|
91
|
+
json={
|
|
92
|
+
"lastSeq": self._seq - 1,
|
|
93
|
+
"droppedBytes": self._dropped_bytes,
|
|
94
|
+
"abandoned": abandoned,
|
|
95
|
+
"discarded": discarded,
|
|
96
|
+
"exitCode": exit_code,
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
self._stopped = True
|
|
100
|
+
|
|
101
|
+
def _post(self, segment: str, *, json=None, data=None, extra_headers: Optional[dict] = None):
|
|
102
|
+
url = f"{self._base_url}/writers/{self._writer_id}/{segment}?attempt={self._attempt}"
|
|
103
|
+
headers = {_TOKEN_HEADER: self._token, **self._extra_headers, **(extra_headers or {})}
|
|
104
|
+
try:
|
|
105
|
+
response = requests.post(
|
|
106
|
+
url, json=json, data=data, headers=headers, timeout=_REQUEST_TIMEOUT_SECONDS
|
|
107
|
+
)
|
|
108
|
+
response.raise_for_status()
|
|
109
|
+
payload = (response.json() or {}).get("data") or {}
|
|
110
|
+
outcome = payload.get("outcome")
|
|
111
|
+
if outcome in _STOP_OUTCOMES:
|
|
112
|
+
self._stopped = True
|
|
113
|
+
return outcome, payload.get("expectedSeq")
|
|
114
|
+
except Exception as exc: # best-effort: never let log streaming break the job itself
|
|
115
|
+
print(f"[log_writer] stopping log stream, request failed: {exc}", file=sys.stderr, flush=True)
|
|
116
|
+
self._stopped = True
|
|
117
|
+
return None, None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def from_input(invocation_input) -> Optional[JobLogWriter]:
|
|
121
|
+
"""Builds a writer from the `logIngest` block the backend injects into the job input, or
|
|
122
|
+
None when push logging is disabled or the job predates this feature. Callers must treat
|
|
123
|
+
None as "don't stream this run" — not as an error."""
|
|
124
|
+
log_ingest = invocation_input.get("logIngest") if isinstance(invocation_input, dict) else None
|
|
125
|
+
if not isinstance(log_ingest, dict):
|
|
126
|
+
return None
|
|
127
|
+
base_url = log_ingest.get("baseUrl")
|
|
128
|
+
token = log_ingest.get("token")
|
|
129
|
+
attempt = log_ingest.get("attempt")
|
|
130
|
+
if not base_url or not token or attempt is None:
|
|
131
|
+
return None
|
|
132
|
+
return JobLogWriter(base_url, token, int(attempt), log_ingest.get("headers"))
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""CLI runner — invoked by the sbatch script inside the Apptainer container.
|
|
2
|
+
|
|
3
|
+
The Java SlurmPlatformManager generates a sbatch script that runs:
|
|
4
|
+
|
|
5
|
+
apptainer exec docker://<image> python -m quapp_slurm_hpc.runner \
|
|
6
|
+
--input-file /data/jobs/<jobId>/input.json \
|
|
7
|
+
--output-file /data/jobs/<jobId>/output.json \
|
|
8
|
+
--handler-dir /home/app/function
|
|
9
|
+
|
|
10
|
+
This module:
|
|
11
|
+
1. Reads the invocation input from --input-file (JSON written by the sbatch script).
|
|
12
|
+
2. Loads handler.py from --handler-dir.
|
|
13
|
+
3. Calls handler.processing(invocation_input) → bash script string.
|
|
14
|
+
4. Executes the bash script, streaming stdout/stderr live to the backend as it runs (when the
|
|
15
|
+
input carries a `logIngest` block) while also capturing it in full for the final result.
|
|
16
|
+
5. Calls handler.post_processing(raw_result) if it exists.
|
|
17
|
+
6. Writes the final result dict to --output-file.
|
|
18
|
+
7. Uploads output.json to S3 (key: {QUAPP_JOB_ID}/output.json) when SLURM_S3_BUCKET is set.
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import importlib.util
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
import traceback
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from types import ModuleType
|
|
32
|
+
|
|
33
|
+
from quapp_slurm_hpc import log_writer
|
|
34
|
+
|
|
35
|
+
_LOG_FLUSH_INTERVAL_SECONDS = 2.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_handler(handler_dir: str) -> ModuleType:
|
|
39
|
+
handler_path = Path(handler_dir) / "handler.py"
|
|
40
|
+
if not handler_path.exists():
|
|
41
|
+
raise FileNotFoundError(
|
|
42
|
+
f"handler.py not found in {handler_dir!r}. "
|
|
43
|
+
"Ensure the function image was built with the handler in /home/app/function."
|
|
44
|
+
)
|
|
45
|
+
if str(handler_dir) not in sys.path:
|
|
46
|
+
sys.path.insert(0, str(handler_dir))
|
|
47
|
+
spec = importlib.util.spec_from_file_location("handler", handler_path)
|
|
48
|
+
module = importlib.util.module_from_spec(spec)
|
|
49
|
+
spec.loader.exec_module(module)
|
|
50
|
+
return module
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run_script(script: str, writer: "log_writer.JobLogWriter | None" = None) -> dict:
|
|
54
|
+
"""Write script to a temp file and execute it with bash, capturing output.
|
|
55
|
+
|
|
56
|
+
Reads stdout/stderr incrementally as the script runs (instead of blocking until it exits)
|
|
57
|
+
so `writer`, when given, can push new output to the backend every
|
|
58
|
+
`_LOG_FLUSH_INTERVAL_SECONDS` seconds — the final returned dict is unchanged either way; the
|
|
59
|
+
full stdout/stderr are still captured in full for `output.json`, same as before.
|
|
60
|
+
"""
|
|
61
|
+
with tempfile.NamedTemporaryFile(
|
|
62
|
+
mode="w", suffix=".sh", delete=False, encoding="utf-8"
|
|
63
|
+
) as f:
|
|
64
|
+
f.write(script)
|
|
65
|
+
tmp_path = f.name
|
|
66
|
+
try:
|
|
67
|
+
process = subprocess.Popen(
|
|
68
|
+
["bash", tmp_path],
|
|
69
|
+
stdout=subprocess.PIPE,
|
|
70
|
+
stderr=subprocess.PIPE,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
stdout_parts: list[bytes] = []
|
|
74
|
+
stderr_parts: list[bytes] = []
|
|
75
|
+
pending_parts: list[bytes] = []
|
|
76
|
+
lock = threading.Lock()
|
|
77
|
+
|
|
78
|
+
def _drain(stream, sink: list[bytes]) -> None:
|
|
79
|
+
for line in iter(stream.readline, b""):
|
|
80
|
+
with lock:
|
|
81
|
+
sink.append(line)
|
|
82
|
+
pending_parts.append(line)
|
|
83
|
+
stream.close()
|
|
84
|
+
|
|
85
|
+
readers = [
|
|
86
|
+
threading.Thread(target=_drain, args=(process.stdout, stdout_parts), daemon=True),
|
|
87
|
+
threading.Thread(target=_drain, args=(process.stderr, stderr_parts), daemon=True),
|
|
88
|
+
]
|
|
89
|
+
for reader in readers:
|
|
90
|
+
reader.start()
|
|
91
|
+
|
|
92
|
+
def _flush() -> None:
|
|
93
|
+
if writer is None:
|
|
94
|
+
return
|
|
95
|
+
with lock:
|
|
96
|
+
if not pending_parts:
|
|
97
|
+
return
|
|
98
|
+
chunk = b"".join(pending_parts)
|
|
99
|
+
pending_parts.clear()
|
|
100
|
+
writer.append(chunk)
|
|
101
|
+
|
|
102
|
+
last_flush = time.monotonic()
|
|
103
|
+
while True:
|
|
104
|
+
finished = process.poll() is not None and not any(r.is_alive() for r in readers)
|
|
105
|
+
now = time.monotonic()
|
|
106
|
+
if finished or now - last_flush >= _LOG_FLUSH_INTERVAL_SECONDS:
|
|
107
|
+
_flush()
|
|
108
|
+
last_flush = now
|
|
109
|
+
if finished:
|
|
110
|
+
break
|
|
111
|
+
time.sleep(0.2)
|
|
112
|
+
|
|
113
|
+
for reader in readers:
|
|
114
|
+
reader.join()
|
|
115
|
+
exit_code = process.wait()
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"exit_code": exit_code,
|
|
119
|
+
"stdout": b"".join(stdout_parts).decode("utf-8", errors="replace"),
|
|
120
|
+
"stderr": b"".join(stderr_parts).decode("utf-8", errors="replace"),
|
|
121
|
+
}
|
|
122
|
+
finally:
|
|
123
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main() -> None:
|
|
127
|
+
parser = argparse.ArgumentParser(
|
|
128
|
+
description="Quapp HPC runner — executes handler.py inside an Apptainer container"
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument("--input-file", required=True, help="Path to input JSON file")
|
|
131
|
+
parser.add_argument("--output-file", required=True, help="Path to write output JSON")
|
|
132
|
+
parser.add_argument("--handler-dir", required=True, help="Directory containing handler.py")
|
|
133
|
+
args = parser.parse_args()
|
|
134
|
+
|
|
135
|
+
output: dict = {}
|
|
136
|
+
writer = None
|
|
137
|
+
exit_code_for_close = 1
|
|
138
|
+
try:
|
|
139
|
+
with open(args.input_file, encoding="utf-8") as f:
|
|
140
|
+
invocation_input = json.load(f)
|
|
141
|
+
|
|
142
|
+
# Absent when log push is disabled or the job predates this feature — _run_script and
|
|
143
|
+
# writer.append/close below all treat `writer is None` as "don't stream", not an error.
|
|
144
|
+
writer = log_writer.from_input(invocation_input)
|
|
145
|
+
if writer is not None:
|
|
146
|
+
writer.open()
|
|
147
|
+
|
|
148
|
+
handler = _load_handler(args.handler_dir)
|
|
149
|
+
|
|
150
|
+
if not hasattr(handler, "processing"):
|
|
151
|
+
raise AttributeError(
|
|
152
|
+
"handler.py must define a processing(invocation_input: dict) -> str function"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
script = handler.processing(invocation_input)
|
|
156
|
+
if not isinstance(script, str):
|
|
157
|
+
raise TypeError(
|
|
158
|
+
f"handler.processing() must return a str (bash script), got {type(script).__name__}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
raw_result = _run_script(script, writer)
|
|
162
|
+
exit_code_for_close = raw_result.get("exit_code", 1)
|
|
163
|
+
|
|
164
|
+
if hasattr(handler, "post_processing"):
|
|
165
|
+
output = handler.post_processing(raw_result)
|
|
166
|
+
else:
|
|
167
|
+
output = raw_result
|
|
168
|
+
|
|
169
|
+
except Exception:
|
|
170
|
+
output = {
|
|
171
|
+
"exit_code": 1,
|
|
172
|
+
"stdout": "",
|
|
173
|
+
"stderr": traceback.format_exc(),
|
|
174
|
+
}
|
|
175
|
+
exit_code_for_close = output["exit_code"]
|
|
176
|
+
finally:
|
|
177
|
+
# Close even when the script never ran (e.g. handler.py failed to load) so a writer
|
|
178
|
+
# that was opened above is never left dangling on the backend.
|
|
179
|
+
if writer is not None:
|
|
180
|
+
writer.close(exit_code_for_close)
|
|
181
|
+
|
|
182
|
+
with open(args.output_file, "w", encoding="utf-8") as f:
|
|
183
|
+
json.dump(output, f)
|
|
184
|
+
|
|
185
|
+
_upload_to_s3(args.output_file)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _upload_to_s3(output_file: str) -> None:
|
|
189
|
+
"""Upload output.json to S3 so the Java backend can read the result.
|
|
190
|
+
|
|
191
|
+
Requires SLURM_S3_BUCKET and QUAPP_JOB_ID env vars (injected by sbatch script).
|
|
192
|
+
Skips silently when the bucket is not configured.
|
|
193
|
+
"""
|
|
194
|
+
s3_bucket = os.getenv("SLURM_S3_BUCKET", "")
|
|
195
|
+
job_uuid = os.getenv("QUAPP_JOB_ID", "")
|
|
196
|
+
if not s3_bucket or not job_uuid:
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
import boto3
|
|
201
|
+
s3 = boto3.client(
|
|
202
|
+
"s3",
|
|
203
|
+
region_name=os.getenv("SLURM_S3_REGION", "ap-southeast-1"),
|
|
204
|
+
aws_access_key_id=os.getenv("SLURM_S3_ACCESS_KEY"),
|
|
205
|
+
aws_secret_access_key=os.getenv("SLURM_S3_SECRET_KEY"),
|
|
206
|
+
)
|
|
207
|
+
s3_key = f"{job_uuid}/output.json"
|
|
208
|
+
s3.upload_file(output_file, s3_bucket, s3_key)
|
|
209
|
+
print(f"[runner] Uploaded output to s3://{s3_bucket}/{s3_key}", flush=True)
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
print(f"[runner] WARNING: S3 upload failed: {exc}", file=sys.stderr, flush=True)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
main()
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
"""CLI runner — invoked by the sbatch script inside the Apptainer container.
|
|
2
|
-
|
|
3
|
-
The Java SlurmPlatformManager generates a sbatch script that runs:
|
|
4
|
-
|
|
5
|
-
apptainer exec docker://<image> python -m quapp_slurm_hpc.runner \
|
|
6
|
-
--input-file /data/jobs/<jobId>/input.json \
|
|
7
|
-
--output-file /data/jobs/<jobId>/output.json \
|
|
8
|
-
--handler-dir /home/app/function
|
|
9
|
-
|
|
10
|
-
This module:
|
|
11
|
-
1. Reads the invocation input from --input-file (JSON written by the sbatch script).
|
|
12
|
-
2. Loads handler.py from --handler-dir.
|
|
13
|
-
3. Calls handler.processing(invocation_input) → bash script string.
|
|
14
|
-
4. Executes the bash script, captures stdout / stderr / exit_code.
|
|
15
|
-
5. Calls handler.post_processing(raw_result) if it exists.
|
|
16
|
-
6. Writes the final result dict to --output-file.
|
|
17
|
-
"""
|
|
18
|
-
import argparse
|
|
19
|
-
import importlib.util
|
|
20
|
-
import json
|
|
21
|
-
import subprocess
|
|
22
|
-
import sys
|
|
23
|
-
import tempfile
|
|
24
|
-
import traceback
|
|
25
|
-
from pathlib import Path
|
|
26
|
-
from types import ModuleType
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _load_handler(handler_dir: str) -> ModuleType:
|
|
30
|
-
handler_path = Path(handler_dir) / "handler.py"
|
|
31
|
-
if not handler_path.exists():
|
|
32
|
-
raise FileNotFoundError(
|
|
33
|
-
f"handler.py not found in {handler_dir!r}. "
|
|
34
|
-
"Ensure the function image was built with the handler in /home/app/function."
|
|
35
|
-
)
|
|
36
|
-
if str(handler_dir) not in sys.path:
|
|
37
|
-
sys.path.insert(0, str(handler_dir))
|
|
38
|
-
spec = importlib.util.spec_from_file_location("handler", handler_path)
|
|
39
|
-
module = importlib.util.module_from_spec(spec)
|
|
40
|
-
spec.loader.exec_module(module)
|
|
41
|
-
return module
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def _run_script(script: str) -> dict:
|
|
45
|
-
"""Write script to a temp file and execute it with bash, capturing output."""
|
|
46
|
-
with tempfile.NamedTemporaryFile(
|
|
47
|
-
mode="w", suffix=".sh", delete=False, encoding="utf-8"
|
|
48
|
-
) as f:
|
|
49
|
-
f.write(script)
|
|
50
|
-
tmp_path = f.name
|
|
51
|
-
try:
|
|
52
|
-
result = subprocess.run(
|
|
53
|
-
["bash", tmp_path],
|
|
54
|
-
capture_output=True,
|
|
55
|
-
text=True,
|
|
56
|
-
)
|
|
57
|
-
return {
|
|
58
|
-
"exit_code": result.returncode,
|
|
59
|
-
"stdout": result.stdout,
|
|
60
|
-
"stderr": result.stderr,
|
|
61
|
-
}
|
|
62
|
-
finally:
|
|
63
|
-
Path(tmp_path).unlink(missing_ok=True)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def main() -> None:
|
|
67
|
-
parser = argparse.ArgumentParser(
|
|
68
|
-
description="Quapp HPC runner — executes handler.py inside an Apptainer container"
|
|
69
|
-
)
|
|
70
|
-
parser.add_argument("--input-file", required=True, help="Path to input JSON file")
|
|
71
|
-
parser.add_argument("--output-file", required=True, help="Path to write output JSON")
|
|
72
|
-
parser.add_argument("--handler-dir", required=True, help="Directory containing handler.py")
|
|
73
|
-
args = parser.parse_args()
|
|
74
|
-
|
|
75
|
-
output: dict = {}
|
|
76
|
-
try:
|
|
77
|
-
with open(args.input_file, encoding="utf-8") as f:
|
|
78
|
-
invocation_input = json.load(f)
|
|
79
|
-
|
|
80
|
-
handler = _load_handler(args.handler_dir)
|
|
81
|
-
|
|
82
|
-
if not hasattr(handler, "processing"):
|
|
83
|
-
raise AttributeError(
|
|
84
|
-
"handler.py must define a processing(invocation_input: dict) -> str function"
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
script = handler.processing(invocation_input)
|
|
88
|
-
if not isinstance(script, str):
|
|
89
|
-
raise TypeError(
|
|
90
|
-
f"handler.processing() must return a str (bash script), got {type(script).__name__}"
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
raw_result = _run_script(script)
|
|
94
|
-
|
|
95
|
-
if hasattr(handler, "post_processing"):
|
|
96
|
-
output = handler.post_processing(raw_result)
|
|
97
|
-
else:
|
|
98
|
-
output = raw_result
|
|
99
|
-
|
|
100
|
-
except Exception:
|
|
101
|
-
output = {
|
|
102
|
-
"exit_code": 1,
|
|
103
|
-
"stdout": "",
|
|
104
|
-
"stderr": traceback.format_exc(),
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
with open(args.output_file, "w", encoding="utf-8") as f:
|
|
108
|
-
json.dump(output, f)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if __name__ == "__main__":
|
|
112
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/component/backend/slurm_job_fetching.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|