quapp-hpc 0.0.1.dev7__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.dev7/quapp_hpc.egg-info → quapp_hpc-0.0.1.dev11}/PKG-INFO +1 -1
  2. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/pyproject.toml +1 -1
  3. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11/quapp_hpc.egg-info}/PKG-INFO +1 -1
  4. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc.egg-info/SOURCES.txt +6 -1
  5. quapp_hpc-0.0.1.dev11/quapp_slurm_hpc/host_wrapper.py +84 -0
  6. quapp_hpc-0.0.1.dev11/quapp_slurm_hpc/log_writer.py +215 -0
  7. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_slurm_hpc/runner.py +34 -13
  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.dev7 → quapp_hpc-0.0.1.dev11}/LICENSE +0 -0
  12. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/README.md +0 -0
  13. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/__init__.py +0 -0
  14. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/component/__init__.py +0 -0
  15. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/component/backend/__init__.py +0 -0
  16. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/component/backend/hpc_invocation.py +0 -0
  17. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/component/backend/slurm_job_fetching.py +0 -0
  18. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/factory/__init__.py +0 -0
  19. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/factory/hpc_device_factory.py +0 -0
  20. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/factory/hpc_handler_factory.py +0 -0
  21. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/factory/hpc_provider_factory.py +0 -0
  22. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/handler/__init__.py +0 -0
  23. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/handler/invocation_handler.py +0 -0
  24. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/handler/job_fetching_handler.py +0 -0
  25. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/model/__init__.py +0 -0
  26. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/model/device/__init__.py +0 -0
  27. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/model/device/slurm_device.py +0 -0
  28. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/model/provider/__init__.py +0 -0
  29. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc/model/provider/slurm_provider.py +0 -0
  30. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc.egg-info/dependency_links.txt +0 -0
  31. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc.egg-info/requires.txt +0 -0
  32. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_hpc.egg-info/top_level.txt +0 -0
  33. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev11}/quapp_slurm_hpc/__init__.py +0 -0
  34. {quapp_hpc-0.0.1.dev7 → 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.dev7
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.dev7"
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.dev7
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,4 +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/runner.py
27
+ quapp_slurm_hpc/host_wrapper.py
28
+ quapp_slurm_hpc/log_writer.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:]))
@@ -0,0 +1,215 @@
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 time
20
+ import uuid
21
+ from typing import Optional
22
+
23
+ import requests
24
+
25
+ _TOKEN_HEADER = "X-Quapp-Log-Token"
26
+ _SEQ_HEADER = "X-Log-Seq"
27
+ _DROPPED_HEADER = "X-Log-Dropped"
28
+ _REQUEST_TIMEOUT_SECONDS = 5
29
+ _STOP_OUTCOMES = {"SEALED", "TOO_LATE", "TRUNCATED"}
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
+
80
+
81
+ class JobLogWriter:
82
+ """A single log writer for one job attempt. Not thread-safe — call from one thread only."""
83
+
84
+ def __init__(self, base_url: str, token: str, attempt: int, headers: Optional[dict] = None):
85
+ self._base_url = base_url.rstrip("/")
86
+ self._token = token
87
+ self._attempt = attempt
88
+ self._extra_headers = headers or {}
89
+ self._writer_id = str(uuid.uuid4())
90
+ self._seq = 0
91
+ self._dropped_bytes = 0
92
+ self._stopped = False
93
+
94
+ @property
95
+ def stopped(self) -> bool:
96
+ """True once the backend told us to stop (sealed/too-late/truncated) or a request
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."""
99
+ return self._stopped
100
+
101
+ def open(self) -> None:
102
+ if self._stopped:
103
+ return
104
+ self._post("open")
105
+
106
+ def append(self, data: bytes) -> None:
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."""
110
+ if not data:
111
+ 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:
121
+ outcome, expected_seq = self._post(
122
+ "chunks",
123
+ data=piece,
124
+ extra_headers={
125
+ _SEQ_HEADER: str(self._seq),
126
+ _DROPPED_HEADER: str(self._dropped_bytes),
127
+ # The backend's /chunks route is declared `consumes = APPLICATION_OCTET_STREAM`
128
+ # and rejects anything else with 415 — requests does not set this on its own
129
+ # for a raw bytes body.
130
+ "Content-Type": "application/octet-stream",
131
+ },
132
+ )
133
+ if outcome in ("APPENDED", "DUPLICATE"):
134
+ self._seq += 1
135
+ self._dropped_bytes = 0
136
+ elif outcome == "SEQ_GAP" and expected_seq is not None:
137
+ # The backend tells us exactly what it's missing — resync instead of retrying the
138
+ # same seq forever (which would never succeed and would spin indefinitely).
139
+ self._seq = int(expected_seq)
140
+ self._dropped_bytes += len(piece)
141
+
142
+ def close(self, exit_code: int, abandoned: bool = False, discarded: bool = False) -> None:
143
+ """Closes the writer. Safe to call even if streaming already stopped earlier (e.g. the
144
+ backend sealed it, or a request failed), so a crash can still attempt a clean close
145
+ from a `finally` block without extra branching."""
146
+ self._post(
147
+ "close",
148
+ json={
149
+ "lastSeq": self._seq - 1,
150
+ "droppedBytes": self._dropped_bytes,
151
+ "abandoned": abandoned,
152
+ "discarded": discarded,
153
+ "exitCode": exit_code,
154
+ },
155
+ )
156
+ self._stopped = True
157
+
158
+ def _post(self, segment: str, *, json=None, data=None, extra_headers: Optional[dict] = None):
159
+ url = f"{self._base_url}/writers/{self._writer_id}/{segment}?attempt={self._attempt}"
160
+ headers = {_TOKEN_HEADER: self._token, **self._extra_headers, **(extra_headers or {})}
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
+
193
+ payload = (response.json() or {}).get("data") or {}
194
+ outcome = payload.get("outcome")
195
+ if outcome in _STOP_OUTCOMES:
196
+ self._stopped = True
197
+ return outcome, payload.get("expectedSeq")
198
+
199
+ self._stopped = True
200
+ return None, None
201
+
202
+
203
+ def from_input(invocation_input) -> Optional[JobLogWriter]:
204
+ """Builds a writer from the `logIngest` block the backend injects into the job input, or
205
+ None when push logging is disabled or the job predates this feature. Callers must treat
206
+ None as "don't stream this run" — not as an error."""
207
+ log_ingest = invocation_input.get("logIngest") if isinstance(invocation_input, dict) else None
208
+ if not isinstance(log_ingest, dict):
209
+ return None
210
+ base_url = log_ingest.get("baseUrl")
211
+ token = log_ingest.get("token")
212
+ attempt = log_ingest.get("attempt")
213
+ if not base_url or not token or attempt is None:
214
+ return None
215
+ return JobLogWriter(base_url, token, int(attempt), log_ingest.get("headers"))
@@ -11,7 +11,8 @@ This module:
11
11
  1. Reads the invocation input from --input-file (JSON written by the sbatch script).
12
12
  2. Loads handler.py from --handler-dir.
13
13
  3. Calls handler.processing(invocation_input) → bash script string.
14
- 4. Executes the bash script, captures stdout / stderr / exit_code.
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.
15
16
  5. Calls handler.post_processing(raw_result) if it exists.
16
17
  6. Writes the final result dict to --output-file.
17
18
  7. Uploads output.json to S3 (key: {QUAPP_JOB_ID}/output.json) when SLURM_S3_BUCKET is set.
@@ -20,13 +21,14 @@ import argparse
20
21
  import importlib.util
21
22
  import json
22
23
  import os
23
- import subprocess
24
24
  import sys
25
25
  import tempfile
26
26
  import traceback
27
27
  from pathlib import Path
28
28
  from types import ModuleType
29
29
 
30
+ from quapp_slurm_hpc import log_writer, stream_exec
31
+
30
32
 
31
33
  def _load_handler(handler_dir: str) -> ModuleType:
32
34
  handler_path = Path(handler_dir) / "handler.py"
@@ -43,23 +45,27 @@ def _load_handler(handler_dir: str) -> ModuleType:
43
45
  return module
44
46
 
45
47
 
46
- def _run_script(script: str) -> dict:
47
- """Write script to a temp file and execute it with bash, capturing output."""
48
+ def _run_script(script: str, writer: "log_writer.JobLogWriter | None" = None) -> dict:
49
+ """Write script to a temp file and execute it with bash, capturing output.
50
+
51
+ Reads stdout/stderr incrementally as the script runs (instead of blocking until it exits)
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.
57
+ """
48
58
  with tempfile.NamedTemporaryFile(
49
59
  mode="w", suffix=".sh", delete=False, encoding="utf-8"
50
60
  ) as f:
51
61
  f.write(script)
52
62
  tmp_path = f.name
53
63
  try:
54
- result = subprocess.run(
55
- ["bash", tmp_path],
56
- capture_output=True,
57
- text=True,
58
- )
64
+ exit_code, stdout_bytes, stderr_bytes = stream_exec.run_streamed(["bash", tmp_path], writer)
59
65
  return {
60
- "exit_code": result.returncode,
61
- "stdout": result.stdout,
62
- "stderr": result.stderr,
66
+ "exit_code": exit_code,
67
+ "stdout": stdout_bytes.decode("utf-8", errors="replace"),
68
+ "stderr": stderr_bytes.decode("utf-8", errors="replace"),
63
69
  }
64
70
  finally:
65
71
  Path(tmp_path).unlink(missing_ok=True)
@@ -75,10 +81,18 @@ def main() -> None:
75
81
  args = parser.parse_args()
76
82
 
77
83
  output: dict = {}
84
+ writer = None
85
+ exit_code_for_close = 1
78
86
  try:
79
87
  with open(args.input_file, encoding="utf-8") as f:
80
88
  invocation_input = json.load(f)
81
89
 
90
+ # Absent when log push is disabled or the job predates this feature — _run_script and
91
+ # writer.append/close below all treat `writer is None` as "don't stream", not an error.
92
+ writer = log_writer.from_input(invocation_input)
93
+ if writer is not None:
94
+ writer.open()
95
+
82
96
  handler = _load_handler(args.handler_dir)
83
97
 
84
98
  if not hasattr(handler, "processing"):
@@ -92,7 +106,8 @@ def main() -> None:
92
106
  f"handler.processing() must return a str (bash script), got {type(script).__name__}"
93
107
  )
94
108
 
95
- raw_result = _run_script(script)
109
+ raw_result = _run_script(script, writer)
110
+ exit_code_for_close = raw_result.get("exit_code", 1)
96
111
 
97
112
  if hasattr(handler, "post_processing"):
98
113
  output = handler.post_processing(raw_result)
@@ -105,6 +120,12 @@ def main() -> None:
105
120
  "stdout": "",
106
121
  "stderr": traceback.format_exc(),
107
122
  }
123
+ exit_code_for_close = output["exit_code"]
124
+ finally:
125
+ # Close even when the script never ran (e.g. handler.py failed to load) so a writer
126
+ # that was opened above is never left dangling on the backend.
127
+ if writer is not None:
128
+ writer.close(exit_code_for_close)
108
129
 
109
130
  with open(args.output_file, "w", encoding="utf-8") as f:
110
131
  json.dump(output, f)
@@ -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