|
| 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