quapp-hpc 0.0.1.dev8__tar.gz → 0.0.1.dev11__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.dev8/quapp_hpc.egg-info → quapp_hpc-0.0.1.dev11}/PKG-INFO +1 -1
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/pyproject.toml +1 -1
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11/quapp_hpc.egg-info}/PKG-INFO +1 -1
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc.egg-info/SOURCES.txt +5 -1
- quapp_hpc-0.0.1.dev11/quapp_slurm_hpc/host_wrapper.py +84 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_slurm_hpc/log_writer.py +101 -18
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_slurm_hpc/runner.py +9 -61
- quapp_hpc-0.0.1.dev11/quapp_slurm_hpc/stream_exec.py +108 -0
- quapp_hpc-0.0.1.dev11/tests/test_log_writer.py +268 -0
- quapp_hpc-0.0.1.dev11/tests/test_stream_exec.py +99 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/LICENSE +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/README.md +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/component/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/component/backend/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/component/backend/hpc_invocation.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/component/backend/slurm_job_fetching.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/factory/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/factory/hpc_device_factory.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/factory/hpc_handler_factory.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/factory/hpc_provider_factory.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/handler/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/handler/invocation_handler.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/handler/job_fetching_handler.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/model/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/model/device/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/model/device/slurm_device.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/model/provider/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/model/provider/slurm_provider.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc.egg-info/dependency_links.txt +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc.egg-info/requires.txt +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc.egg-info/top_level.txt +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_slurm_hpc/__init__.py +0 -0
- {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/setup.cfg +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "quapp-hpc"
|
|
7
|
-
version = "0.0.1.
|
|
7
|
+
version = "0.0.1.dev11"
|
|
8
8
|
description = "Quapp HPC library — Slurm integration for Quapp Platform"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "CITYNOW Co. Ltd.", email = "corp@citynow.vn" }]
|
|
@@ -24,5 +24,9 @@ quapp_hpc/model/device/slurm_device.py
|
|
|
24
24
|
quapp_hpc/model/provider/__init__.py
|
|
25
25
|
quapp_hpc/model/provider/slurm_provider.py
|
|
26
26
|
quapp_slurm_hpc/__init__.py
|
|
27
|
+
quapp_slurm_hpc/host_wrapper.py
|
|
27
28
|
quapp_slurm_hpc/log_writer.py
|
|
28
|
-
quapp_slurm_hpc/runner.py
|
|
29
|
+
quapp_slurm_hpc/runner.py
|
|
30
|
+
quapp_slurm_hpc/stream_exec.py
|
|
31
|
+
tests/test_log_writer.py
|
|
32
|
+
tests/test_stream_exec.py
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Host-side wrapper that streams a command's output live to the backend's log pipeline
|
|
2
|
+
(PQF-23860), the same way `runner.py` streams the function handler's own output — but for the
|
|
3
|
+
*outer* `apptainer exec` invocation itself, run on the bare Slurm compute node, outside any
|
|
4
|
+
container. That outer phase (image pull, OCI→SIF conversion, Apptainer's own warnings like
|
|
5
|
+
"Could not find any nv files on this host!") happens entirely before `runner.py` (tier 1) ever
|
|
6
|
+
starts, so without this wrapper it's invisible anywhere except a manual SSH read of the raw
|
|
7
|
+
`slurm-<jobid>.out` file.
|
|
8
|
+
|
|
9
|
+
Deployed standalone to Slurm compute node hosts — see the `apptainer` Ansible role
|
|
10
|
+
(quapp-infrastructure, cts/slurm/ansible) — independent of the full `quapp-hpc` PyPI package:
|
|
11
|
+
only this file plus `log_writer.py` and `stream_exec.py` side by side, relying solely on
|
|
12
|
+
`requests` + the standard library (already present on these hosts; no pip/package install
|
|
13
|
+
needed). Keep the three files identical to their `quapp_slurm_hpc/` counterparts in this repo.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
python3 host_wrapper.py --input-file <job's input.json> -- <command...>
|
|
17
|
+
|
|
18
|
+
Exits with the wrapped command's exit code. Best-effort like the rest of this feature: any
|
|
19
|
+
failure to read `--input-file`, build a writer, or push log chunks only logs a warning to
|
|
20
|
+
stderr — it never changes the wrapped command's exit code or stops it from running.
|
|
21
|
+
"""
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
import log_writer
|
|
26
|
+
import stream_exec
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv) -> int:
|
|
30
|
+
if "--" not in argv:
|
|
31
|
+
print(
|
|
32
|
+
"usage: host_wrapper.py --input-file <path> -- <command...>",
|
|
33
|
+
file=sys.stderr, flush=True,
|
|
34
|
+
)
|
|
35
|
+
return 2
|
|
36
|
+
sep = argv.index("--")
|
|
37
|
+
command = argv[sep + 1:]
|
|
38
|
+
if not command:
|
|
39
|
+
print("host_wrapper: no command given after --", file=sys.stderr, flush=True)
|
|
40
|
+
return 2
|
|
41
|
+
|
|
42
|
+
input_file = None
|
|
43
|
+
own_args = argv[:sep]
|
|
44
|
+
for i, arg in enumerate(own_args):
|
|
45
|
+
if arg == "--input-file" and i + 1 < len(own_args):
|
|
46
|
+
input_file = own_args[i + 1]
|
|
47
|
+
if not input_file:
|
|
48
|
+
print("host_wrapper: --input-file is required", file=sys.stderr, flush=True)
|
|
49
|
+
return 2
|
|
50
|
+
|
|
51
|
+
writer = None
|
|
52
|
+
try:
|
|
53
|
+
with open(input_file, encoding="utf-8") as f:
|
|
54
|
+
invocation_input = json.load(f)
|
|
55
|
+
writer = log_writer.from_input(invocation_input)
|
|
56
|
+
if writer is not None:
|
|
57
|
+
writer.open()
|
|
58
|
+
except Exception as exc: # best-effort: a broken input.json must not block the real command
|
|
59
|
+
print(f"host_wrapper: could not set up log streaming: {exc}", file=sys.stderr, flush=True)
|
|
60
|
+
writer = None
|
|
61
|
+
|
|
62
|
+
# tee_to writes each line to our own stdout as it's read — not after the command exits — so
|
|
63
|
+
# a killed command (walltime, scancel, OOM) still leaves whatever it already printed in
|
|
64
|
+
# slurm-<jobid>.out, exactly as before this wrapper existed. keep_output=False: nothing here
|
|
65
|
+
# needs the content back (it's already teed), so don't also hold the whole thing in memory.
|
|
66
|
+
exit_code, _, _ = stream_exec.run_streamed(
|
|
67
|
+
command, writer, merge_stderr=True, flush_interval_fn=log_writer.flush_interval,
|
|
68
|
+
tee_to=sys.stdout.buffer, keep_output=False,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if writer is not None:
|
|
72
|
+
# The raw (possibly negative, signal-killed) code — JobLogCloseRequest.exitCode's own
|
|
73
|
+
# contract documents negative-for-a-signal, matching Popen.returncode directly.
|
|
74
|
+
writer.close(exit_code)
|
|
75
|
+
|
|
76
|
+
# This process's own exit status, by contrast, must follow the shell/Slurm convention a
|
|
77
|
+
# negative Popen.returncode doesn't: sys.exit() truncates a negative value to a nonsense
|
|
78
|
+
# positive byte (e.g. -15 -> 241) instead of the 128+signum a killed process is supposed to
|
|
79
|
+
# report (143 for SIGTERM).
|
|
80
|
+
return 128 + (-exit_code) if exit_code < 0 else exit_code
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -16,6 +16,7 @@ failure here must never raise into the handler's own execution — the job still
|
|
|
16
16
|
completion and still produce output.json exactly as before.
|
|
17
17
|
"""
|
|
18
18
|
import sys
|
|
19
|
+
import time
|
|
19
20
|
import uuid
|
|
20
21
|
from typing import Optional
|
|
21
22
|
|
|
@@ -27,6 +28,55 @@ _DROPPED_HEADER = "X-Log-Dropped"
|
|
|
27
28
|
_REQUEST_TIMEOUT_SECONDS = 5
|
|
28
29
|
_STOP_OUTCOMES = {"SEALED", "TOO_LATE", "TRUNCATED"}
|
|
29
30
|
|
|
31
|
+
# Stay under the backend's `invocation.logging.max-chunk-size` (256KB) with margin — a chunk
|
|
32
|
+
# over that limit gets a 400 (JOB_LOG_CHUNK_TOO_LARGE), and since that isn't one of the retryable
|
|
33
|
+
# statuses below, a single oversized flush would otherwise stop the stream for the rest of the job.
|
|
34
|
+
_MAX_CHUNK_BYTES = 250 * 1024
|
|
35
|
+
|
|
36
|
+
# Transient: worth a few retries (same seq — the backend's DUPLICATE outcome makes retrying a
|
|
37
|
+
# chunk that actually was stored safe). Any other status (400, 401, 403, 404, 409…) is treated as
|
|
38
|
+
# non-retryable: retrying the same request wouldn't change the outcome. 401 in particular (token
|
|
39
|
+
# expired after its ~48h TTL) is expected to stop the stream, not retry.
|
|
40
|
+
_TRANSIENT_STATUSES = {429, 500, 502, 503, 504}
|
|
41
|
+
_MAX_ATTEMPTS = 4
|
|
42
|
+
_RETRY_BACKOFF_SECONDS = (1, 2, 5)
|
|
43
|
+
|
|
44
|
+
# How often to flush: often at first for near-real-time visibility on typical short jobs, backing
|
|
45
|
+
# off as the job runs longer so a multi-hour job doesn't exhaust the backend's 2000-chunks/10MB
|
|
46
|
+
# per-attempt cap (at a flat 2s cadence that cap hits at ~66 minutes regardless of chunk size,
|
|
47
|
+
# since every flush spends one chunk even when its content is tiny).
|
|
48
|
+
FLUSH_SCHEDULE = (
|
|
49
|
+
(300.0, 2.0), # first 5 minutes: every 2s
|
|
50
|
+
(1800.0, 10.0), # next 25 minutes: every 10s
|
|
51
|
+
(float("inf"), 30.0), # beyond 30 minutes: every 30s
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def flush_interval(elapsed_seconds: float) -> float:
|
|
56
|
+
"""The flush cadence to use at `elapsed_seconds` into the job, per FLUSH_SCHEDULE."""
|
|
57
|
+
for threshold, interval in FLUSH_SCHEDULE:
|
|
58
|
+
if elapsed_seconds < threshold:
|
|
59
|
+
return interval
|
|
60
|
+
return FLUSH_SCHEDULE[-1][1]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def split_chunks(data: bytes, limit: int = _MAX_CHUNK_BYTES):
|
|
64
|
+
"""Splits `data` into pieces of at most `limit` bytes each, cutting at the last newline
|
|
65
|
+
inside the window when there is one (so a line isn't split mid-line unless a single line
|
|
66
|
+
alone already exceeds `limit`, e.g. a `\\r`-driven progress bar — then it must be)."""
|
|
67
|
+
pieces = []
|
|
68
|
+
start = 0
|
|
69
|
+
n = len(data)
|
|
70
|
+
while start < n:
|
|
71
|
+
end = min(start + limit, n)
|
|
72
|
+
if end < n:
|
|
73
|
+
cut = data.rfind(b"\n", start, end)
|
|
74
|
+
if cut != -1 and cut + 1 > start:
|
|
75
|
+
end = cut + 1
|
|
76
|
+
pieces.append(data[start:end])
|
|
77
|
+
start = end
|
|
78
|
+
return pieces
|
|
79
|
+
|
|
30
80
|
|
|
31
81
|
class JobLogWriter:
|
|
32
82
|
"""A single log writer for one job attempt. Not thread-safe — call from one thread only."""
|
|
@@ -44,8 +94,8 @@ class JobLogWriter:
|
|
|
44
94
|
@property
|
|
45
95
|
def stopped(self) -> bool:
|
|
46
96
|
"""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
|
|
48
|
-
each method is itself a safe no-op either way."""
|
|
97
|
+
failed after retries — callers should skip further append()/close() calls once this is
|
|
98
|
+
set, though each method is itself a safe no-op either way."""
|
|
49
99
|
return self._stopped
|
|
50
100
|
|
|
51
101
|
def open(self) -> None:
|
|
@@ -54,16 +104,23 @@ class JobLogWriter:
|
|
|
54
104
|
self._post("open")
|
|
55
105
|
|
|
56
106
|
def append(self, data: bytes) -> None:
|
|
57
|
-
"""Sends
|
|
58
|
-
streaming has stopped — the handler's own run must
|
|
107
|
+
"""Sends new output, split into chunks the backend will actually accept. Silently drops
|
|
108
|
+
it (and counts it as dropped) once streaming has stopped — the handler's own run must
|
|
109
|
+
never block or fail on this."""
|
|
59
110
|
if not data:
|
|
60
111
|
return
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
112
|
+
# Pass the limit explicitly (not relying on split_chunks's own default) so it is read
|
|
113
|
+
# fresh from the module each call — needed for tests to override it via monkeypatching.
|
|
114
|
+
for piece in split_chunks(data, limit=_MAX_CHUNK_BYTES):
|
|
115
|
+
if self._stopped:
|
|
116
|
+
self._dropped_bytes += len(piece)
|
|
117
|
+
continue
|
|
118
|
+
self._append_one(piece)
|
|
119
|
+
|
|
120
|
+
def _append_one(self, piece: bytes) -> None:
|
|
64
121
|
outcome, expected_seq = self._post(
|
|
65
122
|
"chunks",
|
|
66
|
-
data=
|
|
123
|
+
data=piece,
|
|
67
124
|
extra_headers={
|
|
68
125
|
_SEQ_HEADER: str(self._seq),
|
|
69
126
|
_DROPPED_HEADER: str(self._dropped_bytes),
|
|
@@ -80,7 +137,7 @@ class JobLogWriter:
|
|
|
80
137
|
# The backend tells us exactly what it's missing — resync instead of retrying the
|
|
81
138
|
# same seq forever (which would never succeed and would spin indefinitely).
|
|
82
139
|
self._seq = int(expected_seq)
|
|
83
|
-
self._dropped_bytes += len(
|
|
140
|
+
self._dropped_bytes += len(piece)
|
|
84
141
|
|
|
85
142
|
def close(self, exit_code: int, abandoned: bool = False, discarded: bool = False) -> None:
|
|
86
143
|
"""Closes the writer. Safe to call even if streaming already stopped earlier (e.g. the
|
|
@@ -101,20 +158,46 @@ class JobLogWriter:
|
|
|
101
158
|
def _post(self, segment: str, *, json=None, data=None, extra_headers: Optional[dict] = None):
|
|
102
159
|
url = f"{self._base_url}/writers/{self._writer_id}/{segment}?attempt={self._attempt}"
|
|
103
160
|
headers = {_TOKEN_HEADER: self._token, **self._extra_headers, **(extra_headers or {})}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
161
|
+
for attempt in range(_MAX_ATTEMPTS):
|
|
162
|
+
last_attempt = attempt == _MAX_ATTEMPTS - 1
|
|
163
|
+
try:
|
|
164
|
+
response = requests.post(
|
|
165
|
+
url, json=json, data=data, headers=headers, timeout=_REQUEST_TIMEOUT_SECONDS
|
|
166
|
+
)
|
|
167
|
+
except requests.exceptions.RequestException as exc:
|
|
168
|
+
if not last_attempt:
|
|
169
|
+
time.sleep(_RETRY_BACKOFF_SECONDS[attempt])
|
|
170
|
+
continue
|
|
171
|
+
print(
|
|
172
|
+
f"[log_writer] stopping log stream, request failed after "
|
|
173
|
+
f"{attempt + 1} attempts: {exc}",
|
|
174
|
+
file=sys.stderr, flush=True,
|
|
175
|
+
)
|
|
176
|
+
self._stopped = True
|
|
177
|
+
return None, None
|
|
178
|
+
|
|
179
|
+
if response.status_code in _TRANSIENT_STATUSES and not last_attempt:
|
|
180
|
+
time.sleep(_RETRY_BACKOFF_SECONDS[attempt])
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
response.raise_for_status()
|
|
185
|
+
except requests.exceptions.HTTPError as exc:
|
|
186
|
+
# Non-transient (or retries exhausted): includes 401 once the ~48h token TTL
|
|
187
|
+
# expires, which is expected to stop the stream rather than retry.
|
|
188
|
+
print(f"[log_writer] stopping log stream, request failed: {exc}",
|
|
189
|
+
file=sys.stderr, flush=True)
|
|
190
|
+
self._stopped = True
|
|
191
|
+
return None, None
|
|
192
|
+
|
|
109
193
|
payload = (response.json() or {}).get("data") or {}
|
|
110
194
|
outcome = payload.get("outcome")
|
|
111
195
|
if outcome in _STOP_OUTCOMES:
|
|
112
196
|
self._stopped = True
|
|
113
197
|
return outcome, payload.get("expectedSeq")
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return None, None
|
|
198
|
+
|
|
199
|
+
self._stopped = True
|
|
200
|
+
return None, None
|
|
118
201
|
|
|
119
202
|
|
|
120
203
|
def from_input(invocation_input) -> Optional[JobLogWriter]:
|
|
@@ -21,18 +21,13 @@ import argparse
|
|
|
21
21
|
import importlib.util
|
|
22
22
|
import json
|
|
23
23
|
import os
|
|
24
|
-
import subprocess
|
|
25
24
|
import sys
|
|
26
25
|
import tempfile
|
|
27
|
-
import threading
|
|
28
|
-
import time
|
|
29
26
|
import traceback
|
|
30
27
|
from pathlib import Path
|
|
31
28
|
from types import ModuleType
|
|
32
29
|
|
|
33
|
-
from quapp_slurm_hpc import log_writer
|
|
34
|
-
|
|
35
|
-
_LOG_FLUSH_INTERVAL_SECONDS = 2.0
|
|
30
|
+
from quapp_slurm_hpc import log_writer, stream_exec
|
|
36
31
|
|
|
37
32
|
|
|
38
33
|
def _load_handler(handler_dir: str) -> ModuleType:
|
|
@@ -54,9 +49,11 @@ def _run_script(script: str, writer: "log_writer.JobLogWriter | None" = None) ->
|
|
|
54
49
|
"""Write script to a temp file and execute it with bash, capturing output.
|
|
55
50
|
|
|
56
51
|
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
|
|
58
|
-
`
|
|
59
|
-
|
|
52
|
+
so `writer`, when given, can push new output to the backend on the cadence
|
|
53
|
+
`log_writer.flush_interval()` defines (frequent at first, backing off on a long-running job
|
|
54
|
+
so it doesn't exhaust the backend's per-attempt chunk/size cap) — the final returned dict is
|
|
55
|
+
unchanged either way; the full stdout/stderr are still captured in full for `output.json`,
|
|
56
|
+
same as before.
|
|
60
57
|
"""
|
|
61
58
|
with tempfile.NamedTemporaryFile(
|
|
62
59
|
mode="w", suffix=".sh", delete=False, encoding="utf-8"
|
|
@@ -64,60 +61,11 @@ def _run_script(script: str, writer: "log_writer.JobLogWriter | None" = None) ->
|
|
|
64
61
|
f.write(script)
|
|
65
62
|
tmp_path = f.name
|
|
66
63
|
try:
|
|
67
|
-
|
|
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
|
-
|
|
64
|
+
exit_code, stdout_bytes, stderr_bytes = stream_exec.run_streamed(["bash", tmp_path], writer)
|
|
117
65
|
return {
|
|
118
66
|
"exit_code": exit_code,
|
|
119
|
-
"stdout":
|
|
120
|
-
"stderr":
|
|
67
|
+
"stdout": stdout_bytes.decode("utf-8", errors="replace"),
|
|
68
|
+
"stderr": stderr_bytes.decode("utf-8", errors="replace"),
|
|
121
69
|
}
|
|
122
70
|
finally:
|
|
123
71
|
Path(tmp_path).unlink(missing_ok=True)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Runs a subprocess while streaming its output incrementally to a `log_writer.JobLogWriter`,
|
|
2
|
+
instead of blocking until it exits. Shared by:
|
|
3
|
+
|
|
4
|
+
- `runner.py`'s `_run_script()` — the function handler's own bash script (tier 1 of the HPC
|
|
5
|
+
log-streaming feature), stdout/stderr captured separately for the final output.json.
|
|
6
|
+
- `host_wrapper.py` — wraps the *outer* `apptainer exec` invocation itself (tier 2), so the
|
|
7
|
+
image-pull/SIF-build messages Apptainer prints before the handler ever starts are visible
|
|
8
|
+
too. stdout/stderr merged there, since nothing downstream needs them separate.
|
|
9
|
+
|
|
10
|
+
No import of `log_writer` at module load time: `host_wrapper.py` is deployed standalone to bare
|
|
11
|
+
Slurm compute node hosts (no `quapp_slurm_hpc` package install there, just this file + log_writer.py
|
|
12
|
+
side by side — see the `apptainer` Ansible role), so this module only takes an already-built
|
|
13
|
+
writer and never assumes how it was constructed.
|
|
14
|
+
"""
|
|
15
|
+
import subprocess
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from typing import List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_streamed(
|
|
22
|
+
cmd: List[str],
|
|
23
|
+
writer=None,
|
|
24
|
+
*,
|
|
25
|
+
merge_stderr: bool = False,
|
|
26
|
+
flush_interval_fn=None,
|
|
27
|
+
tee_to=None,
|
|
28
|
+
keep_output: bool = True,
|
|
29
|
+
) -> Tuple[int, bytes, bytes]:
|
|
30
|
+
"""Runs `cmd`, flushing new output to `writer.append()` (if given) on the cadence
|
|
31
|
+
`flush_interval_fn(elapsed_seconds)` returns (defaults to `log_writer.flush_interval`).
|
|
32
|
+
|
|
33
|
+
`tee_to`, when given a writable binary stream (e.g. `sys.stdout.buffer`), gets each line
|
|
34
|
+
written (and flushed) to it as soon as it's read — not after the process exits — so a killed
|
|
35
|
+
process (walltime, `scancel`, OOM) still leaves whatever it already printed somewhere durable
|
|
36
|
+
(`host_wrapper.py` uses this so Apptainer's output still lands in `slurm-<jobid>.out` exactly
|
|
37
|
+
as before this wrapper existed, line by line, instead of only on a clean exit).
|
|
38
|
+
|
|
39
|
+
`keep_output` (default True) buffers everything read into the returned bytes, same as
|
|
40
|
+
before. Set it False when the caller only needs the exit code and already has the content
|
|
41
|
+
some other way (`tee_to`, or doesn't need it) — otherwise a long, chatty command holds its
|
|
42
|
+
entire output in memory for no reason (`host_wrapper.py`'s use case: it tees to its own
|
|
43
|
+
stdout and never looks at the returned bytes).
|
|
44
|
+
|
|
45
|
+
Returns `(exit_code, stdout_bytes, stderr_bytes)` (both empty when `keep_output` is False).
|
|
46
|
+
When `merge_stderr` is True, stderr is redirected into the same stream as stdout, so
|
|
47
|
+
`stdout_bytes` holds everything combined and `stderr_bytes` is always `b""`.
|
|
48
|
+
"""
|
|
49
|
+
if flush_interval_fn is None:
|
|
50
|
+
from quapp_slurm_hpc.log_writer import flush_interval as flush_interval_fn
|
|
51
|
+
|
|
52
|
+
process = subprocess.Popen(
|
|
53
|
+
cmd,
|
|
54
|
+
stdout=subprocess.PIPE,
|
|
55
|
+
stderr=subprocess.STDOUT if merge_stderr else subprocess.PIPE,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
stdout_parts: List[bytes] = []
|
|
59
|
+
stderr_parts: List[bytes] = []
|
|
60
|
+
pending_parts: List[bytes] = []
|
|
61
|
+
lock = threading.Lock()
|
|
62
|
+
|
|
63
|
+
def _drain(stream, sink: List[bytes]) -> None:
|
|
64
|
+
for line in iter(stream.readline, b""):
|
|
65
|
+
if tee_to is not None:
|
|
66
|
+
tee_to.write(line)
|
|
67
|
+
tee_to.flush()
|
|
68
|
+
with lock:
|
|
69
|
+
if keep_output:
|
|
70
|
+
sink.append(line)
|
|
71
|
+
pending_parts.append(line)
|
|
72
|
+
stream.close()
|
|
73
|
+
|
|
74
|
+
readers = [threading.Thread(target=_drain, args=(process.stdout, stdout_parts), daemon=True)]
|
|
75
|
+
if not merge_stderr:
|
|
76
|
+
readers.append(
|
|
77
|
+
threading.Thread(target=_drain, args=(process.stderr, stderr_parts), daemon=True)
|
|
78
|
+
)
|
|
79
|
+
for reader in readers:
|
|
80
|
+
reader.start()
|
|
81
|
+
|
|
82
|
+
def _flush() -> None:
|
|
83
|
+
if writer is None:
|
|
84
|
+
return
|
|
85
|
+
with lock:
|
|
86
|
+
if not pending_parts:
|
|
87
|
+
return
|
|
88
|
+
chunk = b"".join(pending_parts)
|
|
89
|
+
pending_parts.clear()
|
|
90
|
+
writer.append(chunk)
|
|
91
|
+
|
|
92
|
+
start_time = time.monotonic()
|
|
93
|
+
last_flush = start_time
|
|
94
|
+
while True:
|
|
95
|
+
finished = process.poll() is not None and not any(r.is_alive() for r in readers)
|
|
96
|
+
now = time.monotonic()
|
|
97
|
+
if finished or now - last_flush >= flush_interval_fn(now - start_time):
|
|
98
|
+
_flush()
|
|
99
|
+
last_flush = now
|
|
100
|
+
if finished:
|
|
101
|
+
break
|
|
102
|
+
time.sleep(0.2)
|
|
103
|
+
|
|
104
|
+
for reader in readers:
|
|
105
|
+
reader.join()
|
|
106
|
+
exit_code = process.wait()
|
|
107
|
+
|
|
108
|
+
return exit_code, b"".join(stdout_parts), b"".join(stderr_parts)
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Tests for quapp_slurm_hpc.log_writer — the first client of the backend's PQF-23860 ingest
|
|
2
|
+
protocol. No test infra existed in this repo before this file.
|
|
3
|
+
"""
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from quapp_slurm_hpc import log_writer
|
|
10
|
+
from quapp_slurm_hpc.log_writer import JobLogWriter, flush_interval, split_chunks
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _response(status_code=200, outcome=None, expected_seq=None):
|
|
14
|
+
resp = MagicMock()
|
|
15
|
+
resp.status_code = status_code
|
|
16
|
+
if status_code >= 400:
|
|
17
|
+
resp.raise_for_status.side_effect = requests.exceptions.HTTPError(response=resp)
|
|
18
|
+
else:
|
|
19
|
+
resp.raise_for_status.side_effect = None
|
|
20
|
+
resp.json.return_value = {
|
|
21
|
+
"data": {"outcome": outcome, "endOffset": 0, "expectedSeq": expected_seq}
|
|
22
|
+
}
|
|
23
|
+
return resp
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture(autouse=True)
|
|
27
|
+
def no_real_sleep():
|
|
28
|
+
# Retry backoff would otherwise make the failure-path tests slow for no reason.
|
|
29
|
+
with patch.object(log_writer.time, "sleep"):
|
|
30
|
+
yield
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── split_chunks ──────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_split_chunks_under_limit_is_a_single_piece():
|
|
37
|
+
data = b"line 1\nline 2\n"
|
|
38
|
+
assert split_chunks(data, limit=1024) == [data]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_split_chunks_cuts_at_the_last_newline_in_the_window():
|
|
42
|
+
data = b"a" * 10 + b"\n" + b"b" * 10 + b"\n" + b"c" * 10
|
|
43
|
+
pieces = split_chunks(data, limit=15)
|
|
44
|
+
assert b"".join(pieces) == data
|
|
45
|
+
assert all(len(p) <= 15 for p in pieces)
|
|
46
|
+
# First piece ends right after the first line's newline, not mid-line.
|
|
47
|
+
assert pieces[0] == b"a" * 10 + b"\n"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_split_chunks_splits_mid_line_when_a_single_line_exceeds_the_limit():
|
|
51
|
+
# A \r-driven progress bar with no newline for a long stretch must still be split, even
|
|
52
|
+
# though that necessarily cuts inside what is semantically "one line".
|
|
53
|
+
data = b"x" * 50
|
|
54
|
+
pieces = split_chunks(data, limit=20)
|
|
55
|
+
assert b"".join(pieces) == data
|
|
56
|
+
assert all(len(p) <= 20 for p in pieces)
|
|
57
|
+
assert len(pieces) == 3
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_split_chunks_empty_input_yields_no_pieces():
|
|
61
|
+
assert split_chunks(b"", limit=10) == []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── flush_interval ────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_flush_interval_schedule():
|
|
68
|
+
assert flush_interval(0) == 2.0
|
|
69
|
+
assert flush_interval(299) == 2.0
|
|
70
|
+
assert flush_interval(300) == 10.0
|
|
71
|
+
assert flush_interval(1799) == 10.0
|
|
72
|
+
assert flush_interval(1800) == 30.0
|
|
73
|
+
assert flush_interval(999_999) == 30.0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── JobLogWriter.append ───────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_append_splits_oversized_data_into_multiple_requests_each_within_limit():
|
|
80
|
+
# Patch the module constant to a small value so the test data stays readable.
|
|
81
|
+
with patch.object(log_writer, "_MAX_CHUNK_BYTES", 10), \
|
|
82
|
+
patch.object(log_writer.requests, "post") as mock_post:
|
|
83
|
+
mock_post.return_value = _response(outcome="APPENDED")
|
|
84
|
+
writer = JobLogWriter("http://backend/jobs/j1/logs", "tok", 1)
|
|
85
|
+
writer.append(b"y" * 25)
|
|
86
|
+
assert mock_post.call_count == 3 # 10 + 10 + 5 bytes
|
|
87
|
+
for call in mock_post.call_args_list:
|
|
88
|
+
body = call.kwargs["data"]
|
|
89
|
+
assert len(body) <= 10
|
|
90
|
+
assert writer._seq == 3
|
|
91
|
+
assert not writer.stopped
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_append_increments_seq_only_on_appended_or_duplicate():
|
|
95
|
+
writer = JobLogWriter("http://backend/jobs/j1/logs", "tok", 1)
|
|
96
|
+
with patch.object(log_writer.requests, "post") as mock_post:
|
|
97
|
+
mock_post.return_value = _response(outcome="APPENDED")
|
|
98
|
+
writer.append(b"hello\n")
|
|
99
|
+
assert writer._seq == 1
|
|
100
|
+
mock_post.return_value = _response(outcome="DUPLICATE")
|
|
101
|
+
writer.append(b"hello again\n")
|
|
102
|
+
assert writer._seq == 2
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_append_resyncs_seq_on_seq_gap():
|
|
106
|
+
writer = JobLogWriter("http://backend/jobs/j1/logs", "tok", 1)
|
|
107
|
+
writer._seq = 5
|
|
108
|
+
with patch.object(log_writer.requests, "post") as mock_post:
|
|
109
|
+
mock_post.return_value = _response(outcome="SEQ_GAP", expected_seq=9)
|
|
110
|
+
writer.append(b"data\n")
|
|
111
|
+
assert writer._seq == 9
|
|
112
|
+
assert not writer.stopped
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_append_sends_the_correct_headers():
|
|
116
|
+
writer = JobLogWriter("http://backend/jobs/j1/logs", "tok-abc", 3, headers={"X-Tenant": "t1"})
|
|
117
|
+
with patch.object(log_writer.requests, "post") as mock_post:
|
|
118
|
+
mock_post.return_value = _response(outcome="APPENDED")
|
|
119
|
+
writer.append(b"hello\n")
|
|
120
|
+
_, kwargs = mock_post.call_args
|
|
121
|
+
assert kwargs["headers"]["X-Quapp-Log-Token"] == "tok-abc"
|
|
122
|
+
assert kwargs["headers"]["X-Log-Seq"] == "0"
|
|
123
|
+
assert kwargs["headers"]["X-Log-Dropped"] == "0"
|
|
124
|
+
assert kwargs["headers"]["Content-Type"] == "application/octet-stream"
|
|
125
|
+
assert kwargs["headers"]["X-Tenant"] == "t1"
|
|
126
|
+
assert "/writers/" in mock_post.call_args[0][0]
|
|
127
|
+
assert "attempt=3" in mock_post.call_args[0][0]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ── best-effort error handling ────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_transient_network_error_then_success_recovers_without_stopping():
|
|
134
|
+
writer = JobLogWriter("http://backend/jobs/j1/logs", "tok", 1)
|
|
135
|
+
with patch.object(log_writer.requests, "post") as mock_post:
|
|
136
|
+
mock_post.side_effect = [
|
|
137
|
+
requests.exceptions.Timeout("boom"),
|
|
138
|
+
_response(outcome="APPENDED"),
|
|
139
|
+
]
|
|
140
|
+
writer.append(b"hello\n")
|
|
141
|
+
assert mock_post.call_count == 2
|
|
142
|
+
assert writer._seq == 1
|
|
143
|
+
assert not writer.stopped
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_persistent_network_error_exhausts_retries_then_stops():
|
|
147
|
+
writer = JobLogWriter("http://backend/jobs/j1/logs", "tok", 1)
|
|
148
|
+
with patch.object(log_writer.requests, "post") as mock_post:
|
|
149
|
+
mock_post.side_effect = requests.exceptions.ConnectionError("down")
|
|
150
|
+
writer.append(b"hello\n")
|
|
151
|
+
assert mock_post.call_count == log_writer._MAX_ATTEMPTS
|
|
152
|
+
assert writer.stopped
|
|
153
|
+
assert writer._seq == 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_transient_status_5xx_is_retried_then_recovers():
|
|
157
|
+
writer = JobLogWriter("http://backend/jobs/j1/logs", "tok", 1)
|
|
158
|
+
with patch.object(log_writer.requests, "post") as mock_post:
|
|
159
|
+
mock_post.side_effect = [_response(status_code=503), _response(outcome="APPENDED")]
|
|
160
|
+
writer.append(b"hello\n")
|
|
161
|
+
assert mock_post.call_count == 2
|
|
162
|
+
assert not writer.stopped
|
|
163
|
+
assert writer._seq == 1
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_persistent_transient_status_exhausts_retries_then_stops():
|
|
167
|
+
writer = JobLogWriter("http://backend/jobs/j1/logs", "tok", 1)
|
|
168
|
+
with patch.object(log_writer.requests, "post") as mock_post:
|
|
169
|
+
mock_post.return_value = _response(status_code=500)
|
|
170
|
+
writer.append(b"hello\n")
|
|
171
|
+
assert mock_post.call_count == log_writer._MAX_ATTEMPTS
|
|
172
|
+
assert writer.stopped
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_401_stops_immediately_without_retrying():
|
|
176
|
+
writer = JobLogWriter("http://backend/jobs/j1/logs", "tok", 1)
|
|
177
|
+
with patch.object(log_writer.requests, "post") as mock_post:
|
|
178
|
+
mock_post.return_value = _response(status_code=401)
|
|
179
|
+
writer.append(b"hello\n")
|
|
180
|
+
assert mock_post.call_count == 1
|
|
181
|
+
assert writer.stopped
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@pytest.mark.parametrize("outcome", ["SEALED", "TOO_LATE", "TRUNCATED"])
|
|
185
|
+
def test_stop_outcomes_halt_further_sends(outcome):
|
|
186
|
+
writer = JobLogWriter("http://backend/jobs/j1/logs", "tok", 1)
|
|
187
|
+
with patch.object(log_writer.requests, "post") as mock_post:
|
|
188
|
+
mock_post.return_value = _response(outcome=outcome)
|
|
189
|
+
writer.append(b"hello\n")
|
|
190
|
+
assert writer.stopped
|
|
191
|
+
writer.append(b"more\n") # must not call the backend again
|
|
192
|
+
assert mock_post.call_count == 1
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_append_on_already_stopped_writer_only_counts_dropped_bytes():
|
|
196
|
+
writer = JobLogWriter("http://backend/jobs/j1/logs", "tok", 1)
|
|
197
|
+
writer._stopped = True
|
|
198
|
+
with patch.object(log_writer.requests, "post") as mock_post:
|
|
199
|
+
writer.append(b"12345")
|
|
200
|
+
mock_post.assert_not_called()
|
|
201
|
+
assert writer._dropped_bytes == 5
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── open / close ──────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_close_sends_last_seq_and_marks_stopped():
|
|
208
|
+
writer = JobLogWriter("http://backend/jobs/j1/logs", "tok", 1)
|
|
209
|
+
with patch.object(log_writer.requests, "post") as mock_post:
|
|
210
|
+
mock_post.return_value = _response(outcome="APPENDED")
|
|
211
|
+
writer.append(b"hello\n") # seq -> 1
|
|
212
|
+
mock_post.return_value = _response(outcome="CLOSED")
|
|
213
|
+
writer.close(exit_code=0)
|
|
214
|
+
_, kwargs = mock_post.call_args
|
|
215
|
+
assert kwargs["json"]["lastSeq"] == 0
|
|
216
|
+
assert kwargs["json"]["exitCode"] == 0
|
|
217
|
+
assert writer.stopped
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_close_without_any_append_sends_last_seq_minus_one():
|
|
221
|
+
writer = JobLogWriter("http://backend/jobs/j1/logs", "tok", 1)
|
|
222
|
+
with patch.object(log_writer.requests, "post") as mock_post:
|
|
223
|
+
mock_post.return_value = _response(outcome="CLOSED")
|
|
224
|
+
writer.close(exit_code=1)
|
|
225
|
+
_, kwargs = mock_post.call_args
|
|
226
|
+
assert kwargs["json"]["lastSeq"] == -1
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_close_is_safe_to_call_on_an_already_stopped_writer():
|
|
230
|
+
writer = JobLogWriter("http://backend/jobs/j1/logs", "tok", 1)
|
|
231
|
+
writer._stopped = True
|
|
232
|
+
with patch.object(log_writer.requests, "post") as mock_post:
|
|
233
|
+
mock_post.return_value = _response(outcome="CLOSED")
|
|
234
|
+
writer.close(exit_code=0) # must not raise
|
|
235
|
+
assert writer.stopped
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ── from_input ────────────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_from_input_returns_none_when_log_ingest_missing():
|
|
242
|
+
assert log_writer.from_input({"x": 1}) is None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_from_input_returns_none_for_non_dict_input():
|
|
246
|
+
assert log_writer.from_input("not a dict") is None
|
|
247
|
+
assert log_writer.from_input(None) is None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_from_input_returns_none_when_required_fields_missing():
|
|
251
|
+
assert log_writer.from_input({"logIngest": {"baseUrl": "http://x"}}) is None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_from_input_builds_writer_from_a_valid_block():
|
|
255
|
+
writer = log_writer.from_input(
|
|
256
|
+
{
|
|
257
|
+
"logIngest": {
|
|
258
|
+
"baseUrl": "http://backend/jobs/j1/logs",
|
|
259
|
+
"token": "tok",
|
|
260
|
+
"attempt": 2,
|
|
261
|
+
"headers": {"X-Tenant": "t1"},
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
assert isinstance(writer, JobLogWriter)
|
|
266
|
+
assert writer._base_url == "http://backend/jobs/j1/logs"
|
|
267
|
+
assert writer._attempt == 2
|
|
268
|
+
assert writer._extra_headers == {"X-Tenant": "t1"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Tests for quapp_slurm_hpc.stream_exec — the incremental subprocess-capture loop shared by
|
|
2
|
+
runner.py (tier 1) and host_wrapper.py (tier 2). No test infra existed for this before.
|
|
3
|
+
"""
|
|
4
|
+
import io
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from quapp_slurm_hpc import stream_exec
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _FakeWriter:
|
|
11
|
+
"""Records every chunk `append()` receives, in order — stands in for JobLogWriter."""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.chunks = []
|
|
15
|
+
|
|
16
|
+
def append(self, data: bytes) -> None:
|
|
17
|
+
self.chunks.append(data)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _always_flush(_elapsed: float) -> float:
|
|
21
|
+
# A near-zero interval makes every poll tick (every 0.2s) flush — the tests don't need to
|
|
22
|
+
# wait out the real flush_interval() schedule to see output land on the writer.
|
|
23
|
+
return 0.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_exit_code_and_merged_output():
|
|
27
|
+
exit_code, combined, stderr = stream_exec.run_streamed(
|
|
28
|
+
["bash", "-c", "echo out1; echo err1 >&2; echo out2"],
|
|
29
|
+
merge_stderr=True,
|
|
30
|
+
)
|
|
31
|
+
assert exit_code == 0
|
|
32
|
+
assert stderr == b""
|
|
33
|
+
assert combined.count(b"\n") == 3
|
|
34
|
+
assert b"out1" in combined and b"err1" in combined and b"out2" in combined
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_exit_code_and_separate_stdout_stderr():
|
|
38
|
+
exit_code, stdout, stderr = stream_exec.run_streamed(
|
|
39
|
+
["bash", "-c", "echo out1; echo err1 >&2"],
|
|
40
|
+
)
|
|
41
|
+
assert exit_code == 0
|
|
42
|
+
assert stdout == b"out1\n"
|
|
43
|
+
assert stderr == b"err1\n"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_nonzero_exit_code_is_reported():
|
|
47
|
+
exit_code, _, _ = stream_exec.run_streamed(["bash", "-c", "exit 7"])
|
|
48
|
+
assert exit_code == 7
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_writer_receives_flushed_chunks():
|
|
52
|
+
writer = _FakeWriter()
|
|
53
|
+
stream_exec.run_streamed(
|
|
54
|
+
["bash", "-c", "echo hello"], writer, flush_interval_fn=_always_flush,
|
|
55
|
+
)
|
|
56
|
+
assert b"".join(writer.chunks) == b"hello\n"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_tee_to_writes_every_line_as_it_arrives():
|
|
60
|
+
sink = io.BytesIO()
|
|
61
|
+
exit_code, combined, _ = stream_exec.run_streamed(
|
|
62
|
+
["bash", "-c", "echo line1; echo line2"],
|
|
63
|
+
merge_stderr=True,
|
|
64
|
+
tee_to=sink,
|
|
65
|
+
)
|
|
66
|
+
assert exit_code == 0
|
|
67
|
+
assert sink.getvalue() == b"line1\nline2\n" == combined
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_keep_output_false_discards_buffer_but_keeps_exit_code():
|
|
71
|
+
sink = io.BytesIO()
|
|
72
|
+
exit_code, stdout, stderr = stream_exec.run_streamed(
|
|
73
|
+
["bash", "-c", "echo should-not-be-buffered"],
|
|
74
|
+
merge_stderr=True,
|
|
75
|
+
tee_to=sink,
|
|
76
|
+
keep_output=False,
|
|
77
|
+
)
|
|
78
|
+
assert exit_code == 0
|
|
79
|
+
assert stdout == b""
|
|
80
|
+
assert stderr == b""
|
|
81
|
+
# The content isn't lost — it went to tee_to, just not also held in memory.
|
|
82
|
+
assert sink.getvalue() == b"should-not-be-buffered\n"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_tee_to_receives_output_even_when_process_is_killed_mid_run():
|
|
86
|
+
# Regression test for a wrapper that only wrote its tee target *after* the process exited:
|
|
87
|
+
# a killed process (walltime/scancel/OOM in production) must not lose what it already
|
|
88
|
+
# printed before being killed. `timeout` sends SIGTERM partway through, same as Slurm hitting
|
|
89
|
+
# a job's walltime — the run must stay well under the 5s `sleep` it never reaches the end of.
|
|
90
|
+
sink = io.BytesIO()
|
|
91
|
+
exit_code, _, _ = stream_exec.run_streamed(
|
|
92
|
+
["timeout", "0.5", "bash", "-c", "echo before-kill; sleep 5; echo should-never-print"],
|
|
93
|
+
merge_stderr=True,
|
|
94
|
+
tee_to=sink,
|
|
95
|
+
keep_output=False,
|
|
96
|
+
)
|
|
97
|
+
assert exit_code != 0 # killed by timeout, never reached its own `exit 0`
|
|
98
|
+
assert b"before-kill" in sink.getvalue()
|
|
99
|
+
assert b"should-never-print" not in sink.getvalue()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/component/backend/hpc_invocation.py
RENAMED
|
File without changes
|
{quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/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
|