Skip to content

Commit 980ea94

Browse files
Run a gene set analysis end to end: upload validation and the job
Second slice of spec 012. Two layers, neither importing Chainlit, because the awkward parts -- a job that outlasts a chat turn, a submission that succeeds and then fails, a result that must be three sizes for three audiences -- are the parts worth testing, and a browser is not needed to test them. **`upload.py`** caps a file at 20 MB against Chainlit's shipped 500 MB. The host has 4.7 GB free of 88 GB and `~/update-beta-chat.sh` refuses to deploy under 6 GB, so a handful of default-sized uploads would take the chat down *and* block the fix. Shape is checked here rather than by the service, which answers a malformed matrix minutes later through a status field, in R's words. **`job.py`** owns one analysis. `Finished` splits by audience: `for_model` bounded and allow-listed, `links` the capability URL the model must never see, `table_path` the full table for the user. Found while writing it: a one-sample file was told it was not an expression matrix -- it is one, it just cannot be compared against anything -- and `gene_count` returned `-1` for a file too long to finish counting, a sentinel of the same type as a real count, leaving a function whose result gets shown to someone. It is `None` now, and the type says so. **Adversarial review of the finished branch found four more, and three are the same mistake.** *The upload leaked on the likeliest paths.* The group checks sat above the `try`, so a wrong group name or a miscounted label list -- the two mistakes a user actually makes -- returned an error and left their matrix on the disk. The leak was on the paths people take most often. *The loading loop had only the wall-clock bound.* That is the bug I had fixed in `await_result` earlier in the same review, in the same file, twelve lines away. Fixing a loop is not fixing the loops. Both now carry `MAX_POLLS` as well. *`finished` and `failed` were defined on the status objects and used by nobody* -- the loops compared the strings themselves, so "done" had three definitions. One `TERMINAL_STATUSES` now, and `failed` is derived as "terminal and not complete", so a status added later is a failure by default rather than an analysis that silently reports no results. *Result tables were written and never removed.* The upload is deleted the moment it is submitted and then the output was kept forever, at ~2 MB each, on a host with 4.7 GB free. Deleting the input and hoarding the output is not a disk policy. `prune_results` runs before each write, by age then by total size, and only ever touches files this module named. **And the sabotage found a fifth.** Removing the `prune_results` call from `await_result` broke nothing: every pruning test called the function directly, so the directory could have grown forever with the suite green. That it works is not the same as it being called. Sabotage, each against the test written for it: validation back above the `try` fails both deletion tests; removing the prune call fails the new one; unbinding the loading loop hangs, which is the failure mode the bound converts into an error. 67 tests in tests/gsa, ./checks.sh clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent f33d1e6 commit 980ea94

5 files changed

Lines changed: 1084 additions & 3 deletions

File tree

‎src/gsa/client.py‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ def _checked(kind: str, value: str) -> str:
105105
return value
106106

107107

108+
#: Statuses that mean the service has stopped working on it. Listed once so
109+
#: a loop cannot disagree with the dataclass about what "done" means.
110+
TERMINAL_STATUSES = frozenset({"complete", "failed"})
111+
112+
108113
@dataclass(frozen=True)
109114
class LoadingStatus:
110115
"""Progress of `POST /data/load`, which is not instant."""
@@ -116,7 +121,11 @@ class LoadingStatus:
116121

117122
@property
118123
def finished(self) -> bool:
119-
return self.status in {"complete", "failed"}
124+
return self.status in TERMINAL_STATUSES
125+
126+
@property
127+
def failed(self) -> bool:
128+
return self.finished and self.status != "complete"
120129

121130

122131
@dataclass(frozen=True)
@@ -146,11 +155,15 @@ class AnalysisStatus:
146155

147156
@property
148157
def finished(self) -> bool:
149-
return self.status in {"complete", "failed"}
158+
return self.status in TERMINAL_STATUSES
150159

151160
@property
152161
def failed(self) -> bool:
153-
return self.status == "failed"
162+
# Anything terminal that is not success. Derived rather than
163+
# `== "failed"`, so a status added to TERMINAL_STATUSES later is
164+
# treated as a failure by default instead of being silently
165+
# reported as a completed analysis with no results.
166+
return self.finished and self.status != "complete"
154167

155168

156169
class GsaClient:

‎src/gsa/job.py‎

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
"""Run one analysis from start to finished file.
2+
3+
Sits between the client, which knows the service, and the chat, which knows
4+
the user. Nothing here imports Chainlit: the awkward parts -- polling a job
5+
that outlasts a chat turn, a submission that succeeds and then fails, a
6+
result that has to be two different sizes for two different audiences --
7+
are the parts worth testing, and a browser is not needed to test them.
8+
9+
**The submission is a receipt.** Measured: `POST /analysis` returned 200 and
10+
the analysis then failed with `CONNECTION_FORCED - broker forced connection
11+
closure`, visible only through `/status`. So `submit_*` returns an ID and
12+
promises nothing, and `await_result` is where success or failure is decided.
13+
"""
14+
15+
import contextlib
16+
import time
17+
from collections.abc import Awaitable, Callable
18+
from dataclasses import dataclass
19+
from pathlib import Path
20+
21+
from gsa import results as gsa_results
22+
from gsa.client import AnalysisStatus, GsaClient, GsaError
23+
from gsa.upload import Matrix, discard
24+
from util.logging import logging
25+
26+
logger = logging.getLogger(__name__)
27+
28+
#: A PADOG run with 1,000 permutations took minutes in the measured run.
29+
#: This is a ceiling on waiting, not an expectation.
30+
DEFAULT_DEADLINE_SECONDS = 30 * 60
31+
POLL_INTERVAL_SECONDS = 10.0
32+
33+
#: A hard ceiling on how many times the service is asked, independent of the
34+
#: interval.
35+
#:
36+
#: Two bounds are needed, and the second one took two attempts. The
37+
#: wall-clock deadline assumes each turn of the loop waits; the moment
38+
#: sleeping does not sleep -- a patched `_sleep`, a zero interval from a
39+
#: caller -- elapsed time never advances and the loop hammers the service.
40+
#: My first fix derived the limit as `deadline / interval`, which computes
41+
#: the bound from the quantity that is degenerate: `poll_interval=0` gave a
42+
#: limit of 1.8 million, so the guard against a runaway loop was itself
43+
#: unbounded in exactly the case it existed for. It left three pytest
44+
#: processes spinning at 100% CPU on a shared host.
45+
#:
46+
#: At the real 10s interval a 30-minute deadline is 180 polls, so this is
47+
#: generous.
48+
MAX_POLLS = 2_000
49+
50+
#: The service's own default, and the method the round trip was measured
51+
#: with. Camera is faster; choosing between them wants evidence rather than
52+
#: preference, so it is recorded as an open question in spec 012.
53+
DEFAULT_METHOD = "PADOG"
54+
55+
ProgressCallback = Callable[[AnalysisStatus], Awaitable[None]]
56+
57+
58+
class AnalysisFailedError(GsaError):
59+
"""The service accepted the analysis and then could not finish it."""
60+
61+
62+
@dataclass(frozen=True)
63+
class Finished:
64+
"""A completed analysis, split by audience.
65+
66+
Three fields, three destinations, kept apart deliberately:
67+
68+
for_model bounded, allow-listed, safe to put in a prompt
69+
links the user's Pathway Browser view -- a capability URL
70+
that must not reach the model
71+
table_path the full table, for the user to download
72+
"""
73+
74+
analysis_id: str
75+
for_model: dict[str, object]
76+
links: list[tuple[str, str]]
77+
table_path: Path
78+
79+
80+
async def submit_public_dataset(
81+
client: GsaClient,
82+
*,
83+
resource_id: str,
84+
dataset_id: str,
85+
factor: str,
86+
group1: str,
87+
group2: str,
88+
method: str = DEFAULT_METHOD,
89+
deadline_seconds: float = DEFAULT_DEADLINE_SECONDS,
90+
) -> str:
91+
"""Load a public dataset and submit it. Returns an analysis ID.
92+
93+
The matrix is downloaded here and handed straight to `submit`. It is
94+
1.2 MB for a small dataset and never leaves this function.
95+
"""
96+
loading_id = await client.load_public_dataset(resource_id, dataset_id)
97+
# Both bounds, same as `await_result`. This loop had only the
98+
# wall-clock one -- the bug I had just fixed in its sibling, in the same
99+
# file, during the same review. Fixing a loop is not fixing the loops.
100+
deadline = time.monotonic() + deadline_seconds
101+
polls = 0
102+
while True:
103+
status = await client.loading_status(loading_id)
104+
polls += 1
105+
if status.failed:
106+
raise AnalysisFailedError(
107+
f"loading {dataset_id} failed: {status.description}"
108+
)
109+
if status.finished:
110+
break
111+
if polls >= MAX_POLLS or time.monotonic() > deadline:
112+
raise AnalysisFailedError(f"loading {dataset_id} did not finish in time")
113+
await _sleep(POLL_INTERVAL_SECONDS)
114+
115+
summary = await client.dataset_summary(dataset_id)
116+
groups = summary.factors.get(factor)
117+
if not groups:
118+
raise AnalysisFailedError(
119+
f"{dataset_id} has no factor called {factor!r}. "
120+
f"It has: {', '.join(sorted(summary.factors)) or 'none'}."
121+
)
122+
_check_comparable(groups, factor, group1, group2)
123+
124+
matrix = await client.download_matrix(dataset_id)
125+
return await client.submit(
126+
method=method,
127+
dataset_name=dataset_id,
128+
dataset_type=summary.type,
129+
matrix=matrix,
130+
samples=summary.samples,
131+
analysis_group=groups,
132+
group1=group1,
133+
group2=group2,
134+
)
135+
136+
137+
async def submit_uploaded_matrix(
138+
client: GsaClient,
139+
*,
140+
matrix: Matrix,
141+
dataset_type: str,
142+
analysis_group: list[str],
143+
group1: str,
144+
group2: str,
145+
method: str = DEFAULT_METHOD,
146+
) -> str:
147+
"""Submit a user's own matrix, then delete their file.
148+
149+
The file goes whether or not the analysis then succeeds: the service has
150+
its own copy by then, and this host does not have room to keep ours.
151+
`dataset_name` is deliberately not the user's filename -- that is one of
152+
the strings the disclosure rules exist to keep out of a prompt.
153+
"""
154+
# Everything, including the validation, inside the `try`.
155+
#
156+
# The checks used to sit above it, so a wrong group name or a
157+
# miscounted label list -- the two mistakes a user is most likely to
158+
# make -- returned an error and left their file on disk. The disk leak
159+
# happened on exactly the paths people take most often.
160+
try:
161+
_check_comparable(analysis_group, "the grouping you gave", group1, group2)
162+
if len(analysis_group) != len(matrix.samples):
163+
raise AnalysisFailedError(
164+
f"You gave {len(analysis_group)} group labels for "
165+
f"{len(matrix.samples)} samples. There must be one label per "
166+
f"sample, in the order the columns appear."
167+
)
168+
return await client.submit(
169+
method=method,
170+
dataset_name="uploaded",
171+
dataset_type=dataset_type,
172+
matrix=matrix.text,
173+
samples=matrix.samples,
174+
analysis_group=analysis_group,
175+
group1=group1,
176+
group2=group2,
177+
)
178+
finally:
179+
discard(matrix.path)
180+
181+
182+
def _check_comparable(groups: list[str], label: str, group1: str, group2: str) -> None:
183+
distinct = set(groups)
184+
missing = [g for g in (group1, group2) if g not in distinct]
185+
if missing:
186+
raise AnalysisFailedError(
187+
f"{', '.join(missing)} is not a value of {label}. "
188+
f"It has: {', '.join(sorted(distinct))}."
189+
)
190+
if group1 == group2:
191+
raise AnalysisFailedError("The two groups to compare must be different.")
192+
193+
194+
#: How long a written result table is kept, and how much of them in total.
195+
#:
196+
#: The upload is deleted the moment it is submitted, and then the *output*
197+
#: was kept forever -- each table is up to ~2 MB, on a host with 4.7 GB
198+
#: free. Deleting the input and hoarding the output is not a disk policy.
199+
#:
200+
#: The window only has to outlast a user downloading their own results.
201+
RESULT_MAX_AGE_SECONDS = 24 * 60 * 60
202+
RESULT_DIR_MAX_BYTES = 200 * 1024 * 1024
203+
204+
205+
def prune_results(
206+
out_dir: Path,
207+
*,
208+
max_age_seconds: float = RESULT_MAX_AGE_SECONDS,
209+
max_total_bytes: int = RESULT_DIR_MAX_BYTES,
210+
now: float | None = None,
211+
) -> int:
212+
"""Delete old result tables. Returns how many were removed.
213+
214+
Called before each write, so the directory bounds itself and there is
215+
no cron job to forget. Age first, then oldest-first until the total
216+
fits: age alone leaves a burst of results unbounded, and size alone
217+
keeps one stale file forever on a quiet week.
218+
219+
Only ever touches files this module wrote, matched by name. A cleanup
220+
that globs a directory it does not own is one misconfiguration away
221+
from deleting something else.
222+
"""
223+
moment = time.time() if now is None else now
224+
ours = sorted(
225+
(path for path in out_dir.glob("reactome-gsa-*.tsv") if path.is_file()),
226+
key=lambda path: path.stat().st_mtime,
227+
)
228+
229+
removed = 0
230+
surviving: list[Path] = []
231+
for path in ours:
232+
if moment - path.stat().st_mtime > max_age_seconds:
233+
with contextlib.suppress(OSError):
234+
path.unlink()
235+
removed += 1
236+
else:
237+
surviving.append(path)
238+
239+
total = sum(path.stat().st_size for path in surviving if path.exists())
240+
for path in surviving:
241+
if total <= max_total_bytes:
242+
break
243+
try:
244+
size = path.stat().st_size
245+
path.unlink()
246+
except OSError:
247+
continue
248+
total -= size
249+
removed += 1
250+
251+
if removed:
252+
logger.info("pruned gsa result tables", extra={"removed": removed})
253+
return removed
254+
255+
256+
async def await_result(
257+
client: GsaClient,
258+
analysis_id: str,
259+
*,
260+
out_dir: Path,
261+
on_progress: ProgressCallback | None = None,
262+
deadline_seconds: float = DEFAULT_DEADLINE_SECONDS,
263+
poll_interval: float = POLL_INTERVAL_SECONDS,
264+
) -> Finished:
265+
"""Poll until the analysis finishes, then write the table and return.
266+
267+
Raises `AnalysisFailedError` when the service says so -- which is the only
268+
place it ever says so.
269+
"""
270+
deadline = time.monotonic() + deadline_seconds
271+
max_polls = min(
272+
MAX_POLLS, max(2, int(deadline_seconds / max(poll_interval, 1.0)) + 2)
273+
)
274+
polls = 0
275+
276+
while True:
277+
status = await client.analysis_status(analysis_id)
278+
polls += 1
279+
if on_progress is not None:
280+
await on_progress(status)
281+
if status.failed:
282+
raise AnalysisFailedError(status.description or "the analysis failed")
283+
if status.finished:
284+
# Terminal and not failed, so: complete. Asking the status
285+
# object rather than comparing the string again keeps one
286+
# definition of "done" across both loops and the dataclass.
287+
break
288+
if polls >= max_polls or time.monotonic() > deadline:
289+
raise AnalysisFailedError(
290+
f"the analysis did not finish within "
291+
f"{deadline_seconds / 60:.0f} minutes; it may still be "
292+
f"running as {analysis_id}"
293+
)
294+
await _sleep(poll_interval)
295+
296+
parsed = gsa_results.parse(await client.result(analysis_id))
297+
if not parsed.pathways:
298+
# Complete, and yet nothing to report. Better to say so than to
299+
# hand back an empty file and an exact-sounding zero.
300+
raise AnalysisFailedError("the analysis finished but returned no pathway table")
301+
302+
out_dir.mkdir(parents=True, exist_ok=True)
303+
prune_results(out_dir)
304+
table_path = out_dir / f"reactome-gsa-{analysis_id}.tsv"
305+
table_path.write_text(gsa_results.as_tsv(parsed))
306+
logger.info(
307+
"gsa result written",
308+
extra={
309+
"analysis": analysis_id,
310+
"pathways": len(parsed.pathways),
311+
"bytes": table_path.stat().st_size,
312+
},
313+
)
314+
315+
return Finished(
316+
analysis_id=analysis_id,
317+
for_model=gsa_results.for_model(parsed),
318+
links=gsa_results.for_user(parsed),
319+
table_path=table_path,
320+
)
321+
322+
323+
async def _sleep(seconds: float) -> None:
324+
# Indirected so a test can run the poll loop without waiting for it.
325+
import asyncio
326+
327+
await asyncio.sleep(seconds)

0 commit comments

Comments
 (0)