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.
Files changed (34) hide show
  1. {quapp_hpc-0.0.1.dev8/quapp_hpc.egg-info → quapp_hpc-0.0.1.dev11}/PKG-INFO +1 -1
  2. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/pyproject.toml +1 -1
  3. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11/quapp_hpc.egg-info}/PKG-INFO +1 -1
  4. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc.egg-info/SOURCES.txt +5 -1
  5. quapp_hpc-0.0.1.dev11/quapp_slurm_hpc/host_wrapper.py +84 -0
  6. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_slurm_hpc/log_writer.py +101 -18
  7. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_slurm_hpc/runner.py +9 -61
  8. quapp_hpc-0.0.1.dev11/quapp_slurm_hpc/stream_exec.py +108 -0
  9. quapp_hpc-0.0.1.dev11/tests/test_log_writer.py +268 -0
  10. quapp_hpc-0.0.1.dev11/tests/test_stream_exec.py +99 -0
  11. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/LICENSE +0 -0
  12. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/README.md +0 -0
  13. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/__init__.py +0 -0
  14. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/component/__init__.py +0 -0
  15. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/component/backend/__init__.py +0 -0
  16. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/component/backend/hpc_invocation.py +0 -0
  17. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/component/backend/slurm_job_fetching.py +0 -0
  18. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/factory/__init__.py +0 -0
  19. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/factory/hpc_device_factory.py +0 -0
  20. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/factory/hpc_handler_factory.py +0 -0
  21. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/factory/hpc_provider_factory.py +0 -0
  22. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/handler/__init__.py +0 -0
  23. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/handler/invocation_handler.py +0 -0
  24. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/handler/job_fetching_handler.py +0 -0
  25. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/model/__init__.py +0 -0
  26. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/model/device/__init__.py +0 -0
  27. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/model/device/slurm_device.py +0 -0
  28. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/model/provider/__init__.py +0 -0
  29. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc/model/provider/slurm_provider.py +0 -0
  30. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc.egg-info/dependency_links.txt +0 -0
  31. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc.egg-info/requires.txt +0 -0
  32. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_hpc.egg-info/top_level.txt +0 -0
  33. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/quapp_slurm_hpc/__init__.py +0 -0
  34. {quapp_hpc-0.0.1.dev8 → quapp_hpc-0.0.1.dev11}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quapp-hpc
3
- Version: 0.0.1.dev8
3
+ Version: 0.0.1.dev11
4
4
  Summary: Quapp HPC library — Slurm integration for Quapp Platform
5
5
  Author-email: "CITYNOW Co. Ltd." <corp@citynow.vn>
6
6
  License: The MIT License (MIT)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "quapp-hpc"
7
- version = "0.0.1.dev8"
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" }]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quapp-hpc
3
- Version: 0.0.1.dev8
3
+ Version: 0.0.1.dev11
4
4
  Summary: Quapp HPC library — Slurm integration for Quapp Platform
5
5
  Author-email: "CITYNOW Co. Ltd." <corp@citynow.vn>
6
6
  License: The MIT License (MIT)
@@ -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 set, though
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 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."""
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
- if self._stopped:
62
- self._dropped_bytes += len(data)
63
- return
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=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(data)
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
- try:
105
- response = requests.post(
106
- url, json=json, data=data, headers=headers, timeout=_REQUEST_TIMEOUT_SECONDS
107
- )
108
- response.raise_for_status()
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
- 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
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 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.
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
- 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
-
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": b"".join(stdout_parts).decode("utf-8", errors="replace"),
120
- "stderr": b"".join(stderr_parts).decode("utf-8", errors="replace"),
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