Coverage for src / quber / playground / gpu.py: 25%

122 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-09-23 22:14 -0400

1"""The GPU stages of an upload, run on the RunPod worker. 

2 

3The hosted playground has no GPU. Two stages of an upload need one: the 

4docling parse of the document, and the embedding of its chunks at 

5ingestion. Both go to the RunPod serverless worker under the contract the 

6inbound-drop Lambda already uses with it: the job payload carries presigned 

7S3 URLs for everything the worker reads and writes, the worker holds no 

8credentials, and a completion marker written strictly after the outputs says 

9the outputs are whole. The files land in the document's own prefix in the 

10extracts bucket, so a parse's artifacts and an embedding's vectors sit 

11beside everything else the document owns. 

12 

13``configured`` says whether this host sends work there at all. On the 

14developer host, where nothing names an endpoint, the parse runs in-process 

15on the local GPU and the chunks embed locally, as they always have. 

16""" 

17 

18from __future__ import annotations 

19 

20import json 

21import time 

22from pathlib import Path 

23from typing import Any, Callable, Dict, List, Optional 

24from urllib.parse import urlparse 

25 

26import boto3 

27import httpx 

28from botocore.config import Config 

29from loguru import logger 

30 

31from quber.settings import get_settings 

32 

33URL_EXPIRY_SECONDS = 4 * 3600 

34POLL_SECONDS = 5 

35LOG_MIRROR_SECONDS = 10 

36#: A job on a worker freshly placed on a new host pays a ten-gigabyte image 

37#: pull before it runs: eleven minutes measured once, over twenty on another 

38#: night. Either task can be the one that meets a fresh host. 

39PARSE_DEADLINE_SECONDS = 35 * 60 

40EMBED_DEADLINE_SECONDS = 35 * 60 

41 

42#: The worker's artifact names, each with the file the name lands in. The 

43#: worker keys its upload URLs by the first and writes the second. 

44PARSE_ARTIFACTS = {"document": "docling", "confidence": "confidence", "cells": "cells"} 

45 

46Log = Callable[[str], None] 

47 

48 

49def configured() -> bool: 

50 p = get_settings().playground 

51 return bool(p.runpod_endpoint_id and p.runpod_api_key and p.artifacts_uri) 

52 

53 

54def _bucket_and_prefix(doc_key: str) -> tuple[str, str]: 

55 uri = get_settings().playground.artifacts_uri or "" 

56 parsed = urlparse(uri) 

57 return parsed.netloc, parsed.path.strip("/") + "/" + doc_key + "/" 

58 

59 

60def _s3(): 

61 # Signature version 4, explicitly: the extracts bucket is KMS-encrypted 

62 # and S3 refuses a version 2 presigned URL against it with a bare 400, 

63 # which is what a client without a pinned region produces for us-east-1. 

64 return boto3.client( 

65 "s3", 

66 region_name=get_settings().s3_cache.region or "us-east-1", 

67 config=Config(signature_version="s3v4"), 

68 ) 

69 

70 

71def _presign(method: str, bucket: str, key: str) -> str: 

72 return _s3().generate_presigned_url( 

73 method, Params={"Bucket": bucket, "Key": key}, ExpiresIn=URL_EXPIRY_SECONDS 

74 ) 

75 

76 

77def _submit(payload: Dict[str, Any]) -> str: 

78 p = get_settings().playground 

79 response = httpx.post( 

80 f"https://api.runpod.ai/v2/{p.runpod_endpoint_id}/run", 

81 json={"input": payload}, 

82 headers={"Authorization": f"Bearer {p.runpod_api_key}"}, 

83 timeout=30, 

84 ) 

85 response.raise_for_status() 

86 job = response.json() 

87 logger.info("runpod job {} ({})", job.get("id"), job.get("status")) 

88 return job["id"] 

89 

90 

91def _read_if_fresh(bucket: str, key: str, since: float) -> Optional[bytes]: 

92 """The object's bytes, or None when it does not exist or predates ``since``. 

93 

94 Nothing under a document's prefix is ever deleted, so a previous run's 

95 marker and log stay where they are; a run tells its own apart by the 

96 object's write time against the moment it submitted the job.""" 

97 s3 = _s3() 

98 try: 

99 response = s3.get_object(Bucket=bucket, Key=key) 

100 except s3.exceptions.NoSuchKey: 

101 return None 

102 if response["LastModified"].timestamp() < since: 

103 return None 

104 return response["Body"].read() 

105 

106 

107def _read_json_if_fresh(bucket: str, key: str, since: float) -> Optional[Dict[str, Any]]: 

108 body = _read_if_fresh(bucket, key, since) 

109 return json.loads(body) if body is not None else None 

110 

111 

112def _read_text_if_fresh(bucket: str, key: str, since: float) -> Optional[str]: 

113 body = _read_if_fresh(bucket, key, since) 

114 return body.decode("utf-8", "replace") if body is not None else None 

115 

116 

117def _job_status(job_id: str) -> Dict[str, Any]: 

118 p = get_settings().playground 

119 response = httpx.get( 

120 f"https://api.runpod.ai/v2/{p.runpod_endpoint_id}/status/{job_id}", 

121 headers={"Authorization": f"Bearer {p.runpod_api_key}"}, 

122 timeout=30, 

123 ) 

124 response.raise_for_status() 

125 return response.json() 

126 

127 

128def _wait_for_marker( 

129 bucket: str, marker_key: str, log_key: Optional[str], deadline: float, log: Log, job_id: str, since: float 

130) -> Dict[str, Any]: 

131 """Poll until the marker exists, mirroring the worker's log into ``log`` 

132 as it grows. The marker is the truth about the outputs; the job's own 

133 status is watched beside it so a job that ends without writing one, or 

134 that RunPod fails or cancels, is reported at once rather than at the 

135 deadline.""" 

136 started = time.time() 

137 mirrored = 0 

138 last_mirror = 0.0 

139 ended_at: Optional[float] = None 

140 while True: 

141 marker = _read_json_if_fresh(bucket, marker_key, since) 

142 if marker is not None: 

143 if log_key: 

144 text = _read_text_if_fresh(bucket, log_key, since) or "" 

145 for line in text.splitlines()[mirrored:]: 

146 log(line) 

147 if marker.get("status") != "complete": 

148 raise RuntimeError( 

149 f"worker failed at {marker.get('stage', '?')}: {marker.get('error', 'no error recorded')}" 

150 ) 

151 return marker 

152 elapsed = time.time() - started 

153 if elapsed > deadline: 

154 raise TimeoutError(f"no completion marker after {int(elapsed)}s ({marker_key})") 

155 status = _job_status(job_id) 

156 state = status.get("status") 

157 if state in ("FAILED", "CANCELLED", "TIMED_OUT"): 

158 raise RuntimeError( 

159 f"worker job {job_id} {state}: {json.dumps(status.get('error') or status.get('output'))[:800]}" 

160 ) 

161 if state == "COMPLETED": 

162 # The marker is written before the handler returns, so a completed 

163 # job with no marker a moment later returned without doing the work. 

164 ended_at = ended_at or time.time() 

165 if time.time() - ended_at > 3 * POLL_SECONDS: 

166 raise RuntimeError( 

167 f"worker job {job_id} completed without a completion marker; output: " 

168 f"{json.dumps(status.get('output'))[:800]}" 

169 ) 

170 if log_key and time.time() - last_mirror >= LOG_MIRROR_SECONDS: 

171 last_mirror = time.time() 

172 text = _read_text_if_fresh(bucket, log_key, since) 

173 if text: 

174 lines = text.splitlines() 

175 for line in lines[mirrored:]: 

176 log(line) 

177 mirrored = len(lines) 

178 time.sleep(POLL_SECONDS) 

179 

180 

181def parse(doc_key: str, workdir: Path, log: Log) -> None: 

182 """Parse the document on the worker and bring the three parse artifacts 

183 into ``workdir`` for fusion. The PDF must already be in the document's 

184 prefix as ``<doc_key>.pdf``.""" 

185 bucket, prefix = _bucket_and_prefix(doc_key) 

186 marker_key = f"{prefix}{doc_key}.parse.complete.json" 

187 log_key = f"{prefix}{doc_key}.parse.log" 

188 s3 = _s3() 

189 # Clock skew between this host and S3 is absorbed by a minute's grace. 

190 since = time.time() - 60 

191 payload = { 

192 "base": doc_key, 

193 "pdf_url": _presign("get_object", bucket, f"{prefix}{doc_key}.pdf"), 

194 "artifact_urls": { 

195 name: _presign("put_object", bucket, f"{prefix}{doc_key}.{stem}.json") 

196 for name, stem in PARSE_ARTIFACTS.items() 

197 }, 

198 "marker_url": _presign("put_object", bucket, marker_key), 

199 "log_url": _presign("put_object", bucket, log_key), 

200 } 

201 job_id = _submit(payload) 

202 log(f"parse submitted to the GPU worker as job {job_id}") 

203 marker = _wait_for_marker(bucket, marker_key, log_key, PARSE_DEADLINE_SECONDS, log, job_id, since) 

204 log( 

205 f"parse complete: {marker.get('pages')} pages, {marker.get('tables')} tables, {marker.get('total_seconds')}s" 

206 ) 

207 for stem in PARSE_ARTIFACTS.values(): 

208 s3.download_file(bucket, f"{prefix}{doc_key}.{stem}.json", str(workdir / f"{doc_key}.{stem}.json")) 

209 

210 

211def embed(doc_key: str, texts: List[str], log: Log = logger.info) -> List[List[float]]: 

212 """Embed the chunks on the worker. The texts go up as one file, the 

213 vectors come back as one file, both kept in the document's prefix.""" 

214 bucket, prefix = _bucket_and_prefix(doc_key) 

215 chunks_key = f"{prefix}{doc_key}.chunks.json" 

216 vectors_key = f"{prefix}{doc_key}.vectors.json" 

217 marker_key = f"{prefix}{doc_key}.embed.complete.json" 

218 log_key = f"{prefix}{doc_key}.embed.log" 

219 s3 = _s3() 

220 since = time.time() - 60 

221 s3.put_object(Bucket=bucket, Key=chunks_key, Body=json.dumps({"texts": texts}).encode("utf-8")) 

222 payload = { 

223 "task": "embed", 

224 "base": doc_key, 

225 "chunks_url": _presign("get_object", bucket, chunks_key), 

226 "vectors_url": _presign("put_object", bucket, vectors_key), 

227 "marker_url": _presign("put_object", bucket, marker_key), 

228 "log_url": _presign("put_object", bucket, log_key), 

229 } 

230 job_id = _submit(payload) 

231 log(f"embedding of {len(texts)} chunks submitted to the GPU worker as job {job_id}") 

232 marker = _wait_for_marker(bucket, marker_key, log_key, EMBED_DEADLINE_SECONDS, log, job_id, since) 

233 vectors = json.loads(s3.get_object(Bucket=bucket, Key=vectors_key)["Body"].read())["vectors"] 

234 if len(vectors) != len(texts): 

235 raise RuntimeError(f"worker returned {len(vectors)} vectors for {len(texts)} chunks") 

236 log(f"embedding complete: {len(vectors)} vectors in {marker.get('total_seconds')}s") 

237 return vectors