quapp-hpc 0.0.1.dev7__tar.gz → 0.0.1.dev8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {quapp_hpc-0.0.1.dev7/quapp_hpc.egg-info → quapp_hpc-0.0.1.dev8}/PKG-INFO +1 -1
  2. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/pyproject.toml +1 -1
  3. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8/quapp_hpc.egg-info}/PKG-INFO +1 -1
  4. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc.egg-info/SOURCES.txt +1 -0
  5. quapp_hpc-0.0.1.dev8/quapp_slurm_hpc/log_writer.py +132 -0
  6. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_slurm_hpc/runner.py +83 -10
  7. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/LICENSE +0 -0
  8. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/README.md +0 -0
  9. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/__init__.py +0 -0
  10. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/component/__init__.py +0 -0
  11. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/component/backend/__init__.py +0 -0
  12. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/component/backend/hpc_invocation.py +0 -0
  13. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/component/backend/slurm_job_fetching.py +0 -0
  14. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/factory/__init__.py +0 -0
  15. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/factory/hpc_device_factory.py +0 -0
  16. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/factory/hpc_handler_factory.py +0 -0
  17. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/factory/hpc_provider_factory.py +0 -0
  18. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/handler/__init__.py +0 -0
  19. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/handler/invocation_handler.py +0 -0
  20. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/handler/job_fetching_handler.py +0 -0
  21. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/model/__init__.py +0 -0
  22. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/model/device/__init__.py +0 -0
  23. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/model/device/slurm_device.py +0 -0
  24. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/model/provider/__init__.py +0 -0
  25. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc/model/provider/slurm_provider.py +0 -0
  26. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc.egg-info/dependency_links.txt +0 -0
  27. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc.egg-info/requires.txt +0 -0
  28. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_hpc.egg-info/top_level.txt +0 -0
  29. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/quapp_slurm_hpc/__init__.py +0 -0
  30. {quapp_hpc-0.0.1.dev7 → quapp_hpc-0.0.1.dev8}/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.dev8
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.dev8"
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.dev8
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,5 @@ 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/log_writer.py
27
28
  quapp_slurm_hpc/runner.py
@@ -0,0 +1,132 @@
1
+ """Pushes a job's stdout/stderr to the backend's live log pipeline (PQF-23860) as it runs,
2
+ instead of only via the final output.json snapshot uploaded at the end.
3
+
4
+ No client for this protocol exists anywhere yet (checked qapp-lib / quapp-libs) — this is the
5
+ first one, reverse-engineered directly from the backend's ingest code:
6
+
7
+ JobLogIngestControllerV1 -- POST {base_url}/writers/{writer_id}/open?attempt=N
8
+ -- POST {base_url}/writers/{writer_id}/chunks?attempt=N
9
+ -- POST {base_url}/writers/{writer_id}/close?attempt=N
10
+ JobLogWriteResult -- the response body, wrapped by the backend's global envelope
11
+ as {"data": {"outcome": ..., "endOffset": ..., "expectedSeq": ...}}
12
+
13
+ Every call carries the token the backend issued for this job attempt in the `logIngest` block
14
+ of the invocation input (see JobLogIngestPayloadProvider on the backend). Best-effort only: a
15
+ failure here must never raise into the handler's own execution — the job still has to run to
16
+ completion and still produce output.json exactly as before.
17
+ """
18
+ import sys
19
+ import uuid
20
+ from typing import Optional
21
+
22
+ import requests
23
+
24
+ _TOKEN_HEADER = "X-Quapp-Log-Token"
25
+ _SEQ_HEADER = "X-Log-Seq"
26
+ _DROPPED_HEADER = "X-Log-Dropped"
27
+ _REQUEST_TIMEOUT_SECONDS = 5
28
+ _STOP_OUTCOMES = {"SEALED", "TOO_LATE", "TRUNCATED"}
29
+
30
+
31
+ class JobLogWriter:
32
+ """A single log writer for one job attempt. Not thread-safe — call from one thread only."""
33
+
34
+ def __init__(self, base_url: str, token: str, attempt: int, headers: Optional[dict] = None):
35
+ self._base_url = base_url.rstrip("/")
36
+ self._token = token
37
+ self._attempt = attempt
38
+ self._extra_headers = headers or {}
39
+ self._writer_id = str(uuid.uuid4())
40
+ self._seq = 0
41
+ self._dropped_bytes = 0
42
+ self._stopped = False
43
+
44
+ @property
45
+ def stopped(self) -> bool:
46
+ """True once the backend told us to stop (sealed/too-late/truncated) or a request
47
+ failed — callers should skip further append()/close() calls once this is set, though
48
+ each method is itself a safe no-op either way."""
49
+ return self._stopped
50
+
51
+ def open(self) -> None:
52
+ if self._stopped:
53
+ return
54
+ self._post("open")
55
+
56
+ def append(self, data: bytes) -> None:
57
+ """Sends one chunk of new output. Silently drops it (and counts it as dropped) once
58
+ streaming has stopped — the handler's own run must never block or fail on this."""
59
+ if not data:
60
+ return
61
+ if self._stopped:
62
+ self._dropped_bytes += len(data)
63
+ return
64
+ outcome, expected_seq = self._post(
65
+ "chunks",
66
+ data=data,
67
+ extra_headers={
68
+ _SEQ_HEADER: str(self._seq),
69
+ _DROPPED_HEADER: str(self._dropped_bytes),
70
+ # The backend's /chunks route is declared `consumes = APPLICATION_OCTET_STREAM`
71
+ # and rejects anything else with 415 — requests does not set this on its own
72
+ # for a raw bytes body.
73
+ "Content-Type": "application/octet-stream",
74
+ },
75
+ )
76
+ if outcome in ("APPENDED", "DUPLICATE"):
77
+ self._seq += 1
78
+ self._dropped_bytes = 0
79
+ elif outcome == "SEQ_GAP" and expected_seq is not None:
80
+ # The backend tells us exactly what it's missing — resync instead of retrying the
81
+ # same seq forever (which would never succeed and would spin indefinitely).
82
+ self._seq = int(expected_seq)
83
+ self._dropped_bytes += len(data)
84
+
85
+ def close(self, exit_code: int, abandoned: bool = False, discarded: bool = False) -> None:
86
+ """Closes the writer. Safe to call even if streaming already stopped earlier (e.g. the
87
+ backend sealed it, or a request failed), so a crash can still attempt a clean close
88
+ from a `finally` block without extra branching."""
89
+ self._post(
90
+ "close",
91
+ json={
92
+ "lastSeq": self._seq - 1,
93
+ "droppedBytes": self._dropped_bytes,
94
+ "abandoned": abandoned,
95
+ "discarded": discarded,
96
+ "exitCode": exit_code,
97
+ },
98
+ )
99
+ self._stopped = True
100
+
101
+ def _post(self, segment: str, *, json=None, data=None, extra_headers: Optional[dict] = None):
102
+ url = f"{self._base_url}/writers/{self._writer_id}/{segment}?attempt={self._attempt}"
103
+ headers = {_TOKEN_HEADER: self._token, **self._extra_headers, **(extra_headers or {})}
104
+ try:
105
+ response = requests.post(
106
+ url, json=json, data=data, headers=headers, timeout=_REQUEST_TIMEOUT_SECONDS
107
+ )
108
+ response.raise_for_status()
109
+ payload = (response.json() or {}).get("data") or {}
110
+ outcome = payload.get("outcome")
111
+ if outcome in _STOP_OUTCOMES:
112
+ self._stopped = True
113
+ return outcome, payload.get("expectedSeq")
114
+ except Exception as exc: # best-effort: never let log streaming break the job itself
115
+ print(f"[log_writer] stopping log stream, request failed: {exc}", file=sys.stderr, flush=True)
116
+ self._stopped = True
117
+ return None, None
118
+
119
+
120
+ def from_input(invocation_input) -> Optional[JobLogWriter]:
121
+ """Builds a writer from the `logIngest` block the backend injects into the job input, or
122
+ None when push logging is disabled or the job predates this feature. Callers must treat
123
+ None as "don't stream this run" — not as an error."""
124
+ log_ingest = invocation_input.get("logIngest") if isinstance(invocation_input, dict) else None
125
+ if not isinstance(log_ingest, dict):
126
+ return None
127
+ base_url = log_ingest.get("baseUrl")
128
+ token = log_ingest.get("token")
129
+ attempt = log_ingest.get("attempt")
130
+ if not base_url or not token or attempt is None:
131
+ return None
132
+ return JobLogWriter(base_url, token, int(attempt), log_ingest.get("headers"))
@@ -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.
@@ -23,10 +24,16 @@ import os
23
24
  import subprocess
24
25
  import sys
25
26
  import tempfile
27
+ import threading
28
+ import time
26
29
  import traceback
27
30
  from pathlib import Path
28
31
  from types import ModuleType
29
32
 
33
+ from quapp_slurm_hpc import log_writer
34
+
35
+ _LOG_FLUSH_INTERVAL_SECONDS = 2.0
36
+
30
37
 
31
38
  def _load_handler(handler_dir: str) -> ModuleType:
32
39
  handler_path = Path(handler_dir) / "handler.py"
@@ -43,23 +50,74 @@ def _load_handler(handler_dir: str) -> ModuleType:
43
50
  return module
44
51
 
45
52
 
46
- def _run_script(script: str) -> dict:
47
- """Write script to a temp file and execute it with bash, capturing output."""
53
+ def _run_script(script: str, writer: "log_writer.JobLogWriter | None" = None) -> dict:
54
+ """Write script to a temp file and execute it with bash, capturing output.
55
+
56
+ Reads stdout/stderr incrementally as the script runs (instead of blocking until it exits)
57
+ so `writer`, when given, can push new output to the backend every
58
+ `_LOG_FLUSH_INTERVAL_SECONDS` seconds — the final returned dict is unchanged either way; the
59
+ full stdout/stderr are still captured in full for `output.json`, same as before.
60
+ """
48
61
  with tempfile.NamedTemporaryFile(
49
62
  mode="w", suffix=".sh", delete=False, encoding="utf-8"
50
63
  ) as f:
51
64
  f.write(script)
52
65
  tmp_path = f.name
53
66
  try:
54
- result = subprocess.run(
67
+ process = subprocess.Popen(
55
68
  ["bash", tmp_path],
56
- capture_output=True,
57
- text=True,
69
+ stdout=subprocess.PIPE,
70
+ stderr=subprocess.PIPE,
58
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
+
59
117
  return {
60
- "exit_code": result.returncode,
61
- "stdout": result.stdout,
62
- "stderr": result.stderr,
118
+ "exit_code": exit_code,
119
+ "stdout": b"".join(stdout_parts).decode("utf-8", errors="replace"),
120
+ "stderr": b"".join(stderr_parts).decode("utf-8", errors="replace"),
63
121
  }
64
122
  finally:
65
123
  Path(tmp_path).unlink(missing_ok=True)
@@ -75,10 +133,18 @@ def main() -> None:
75
133
  args = parser.parse_args()
76
134
 
77
135
  output: dict = {}
136
+ writer = None
137
+ exit_code_for_close = 1
78
138
  try:
79
139
  with open(args.input_file, encoding="utf-8") as f:
80
140
  invocation_input = json.load(f)
81
141
 
142
+ # Absent when log push is disabled or the job predates this feature — _run_script and
143
+ # writer.append/close below all treat `writer is None` as "don't stream", not an error.
144
+ writer = log_writer.from_input(invocation_input)
145
+ if writer is not None:
146
+ writer.open()
147
+
82
148
  handler = _load_handler(args.handler_dir)
83
149
 
84
150
  if not hasattr(handler, "processing"):
@@ -92,7 +158,8 @@ def main() -> None:
92
158
  f"handler.processing() must return a str (bash script), got {type(script).__name__}"
93
159
  )
94
160
 
95
- raw_result = _run_script(script)
161
+ raw_result = _run_script(script, writer)
162
+ exit_code_for_close = raw_result.get("exit_code", 1)
96
163
 
97
164
  if hasattr(handler, "post_processing"):
98
165
  output = handler.post_processing(raw_result)
@@ -105,6 +172,12 @@ def main() -> None:
105
172
  "stdout": "",
106
173
  "stderr": traceback.format_exc(),
107
174
  }
175
+ exit_code_for_close = output["exit_code"]
176
+ finally:
177
+ # Close even when the script never ran (e.g. handler.py failed to load) so a writer
178
+ # that was opened above is never left dangling on the backend.
179
+ if writer is not None:
180
+ writer.close(exit_code_for_close)
108
181
 
109
182
  with open(args.output_file, "w", encoding="utf-8") as f:
110
183
  json.dump(output, f)
File without changes
File without changes
File without changes