Coverage for src/local_deep_research/journal_quality/downloader.py: 86%

226 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Bulk-fetch and lazy-load journal-quality datasets. 

2 

3This module is now a thin orchestration layer over the 

4`data_sources` package. Each academic dataset is a `DataSource` 

5subclass; this module just iterates `ALL_SOURCES` to drive the bulk 

6download flow (the dashboard "Download Data" button) and to compute the 

7status payload returned by the `/metrics/api/journal-data/status` 

8endpoint. 

9 

10Public API (kept stable for existing callers and tests): 

11- `JOURNAL_DATA_VERSION` 

12- `REQUIRED_SNAPSHOT_FILENAME` 

13- `get_journal_data_status()` 

14- `download_journal_data(force=False)` 

15- `ensure_journal_data(auto_download=True)` 

16 

17Test-patch surface (kept stable for tests that mock by string path): 

18- `_get_data_dir` 

19- `_fetch_openalex_sources` (re-export shim) 

20- `_fetch_doaj_journals` (re-export shim) 

21 

22Data source registry lives in `.data_sources` and is the single source 

23of truth for source metadata, fetch logic, and download policy. 

24""" 

25 

26from __future__ import annotations 

27 

28import json 

29import os 

30import time 

31from pathlib import Path 

32from typing import Optional 

33 

34from loguru import logger 

35 

36from .data_sources import ALL_SOURCES, get_source 

37 

38JOURNAL_DATA_VERSION = "v4" 

39 

40#: The one REQUIRED snapshot, and therefore the predicate that decides 

41#: whether a journal-DB build needs the network at all: with this file in 

42#: the data directory ``ensure_journal_data`` reports "available" without 

43#: a single request and ``JournalQualityDB._build_or_raise`` compiles 

44#: straight from disk. Named here rather than repeated as a literal so 

45#: the callers that PREDICT a download — ``web/routers/metrics.py``'s 

46#: ``_journal_ref_db_read_would_download`` — cannot drift from the caller 

47#: that performs one. 

48REQUIRED_SNAPSHOT_FILENAME = "openalex_sources.json.gz" 

49 

50_SENTINEL = ".downloading" 

51 

52 

53# If the sentinel is older than this, assume the previous download 

54# crashed mid-way (thread died before the `finally` cleanup could run) 

55# and reclaim it. The expected wall-clock is ~7 minutes, so 20 minutes 

56# is a generous safety margin that still catches stuck sentinels. 

57_SENTINEL_STALE_SECS = 20 * 60 

58 

59# Shared progress state for the dashboard's status endpoint. 

60# A module-level dict is deliberate: there's only ever one concurrent 

61# download (enforced by the O_EXCL sentinel), and the status endpoint 

62# reads it on a best-effort basis. Structure: 

63# state: "idle" | "running" | "error" | "success" 

64# started_at: epoch seconds, or None 

65# finished_at: epoch seconds, or None 

66# sources: {src_key: {name, state, detail, count}} 

67# db_build: {state, detail} 

68# error_msg: str or None 

69# 

70# Per-source entries track independent parallel downloads. Writes are 

71# atomic at the per-key level in CPython, so workers can update their 

72# own sub-dict without a lock; the main thread composes the overall 

73# `state` / `error_msg` after joining. 

74_download_state: dict = { 

75 "state": "idle", 

76 "started_at": None, 

77 "finished_at": None, 

78 "sources": {}, 

79 "db_build": {"state": "pending", "detail": ""}, 

80 "error_msg": None, 

81 # Per-source final counts from the most recently COMPLETED download. 

82 # Set to a dict only on the success path; explicitly invalidated to 

83 # None on every other return in `download_journal_data` (up-to-date, 

84 # disk-space, sentinel-race, required-failure, DB-build-failure) so 

85 # callers cannot read stale counts from a prior successful run. 

86 # Callers rendering a user-facing summary should prefer this 

87 # structured field over parsing the `(success, message)` tuple's 

88 # string. 

89 "counts": None, 

90} 

91 

92 

93def get_download_state() -> dict: 

94 """Return a copy of the current download progress state. 

95 

96 A shallow dict copy is not enough — callers (the status endpoint) 

97 serialize this to JSON and would otherwise race with live updates. 

98 We copy the nested dicts too. 

99 """ 

100 counts = _download_state["counts"] 

101 return { 

102 "state": _download_state["state"], 

103 "started_at": _download_state["started_at"], 

104 "finished_at": _download_state["finished_at"], 

105 "db_build": dict(_download_state["db_build"]), 

106 "error_msg": _download_state["error_msg"], 

107 "sources": {k: dict(v) for k, v in _download_state["sources"].items()}, 

108 "counts": dict(counts) if counts is not None else None, 

109 } 

110 

111 

112def _set_source_state(key: str, **updates) -> None: 

113 """Update a single source's state entry. Safe to call from worker 

114 threads because the only writer of ``sources[key]`` is that key's 

115 own worker (CPython guarantees dict-item writes are atomic). 

116 """ 

117 entry = _download_state["sources"].setdefault( 

118 key, {"name": key, "state": "pending", "detail": "", "count": 0} 

119 ) 

120 entry.update(updates) 

121 logger.info( 

122 f"journal-data progress: {entry.get('name', key)} " 

123 f"{entry.get('state', '?')} {entry.get('detail', '')}".rstrip() 

124 ) 

125 

126 

127def _get_data_dir() -> Path: 

128 """Get the journal data directory (user-writable). 

129 

130 Kept as a module-level function so tests can patch it via 

131 `mock.patch("...journal_data_downloader._get_data_dir", ...)`. 

132 """ 

133 from ..config.paths import get_journal_data_directory 

134 

135 return get_journal_data_directory() 

136 

137 

138def _clear_orphan_sentinel_on_startup() -> None: 

139 """Remove any ``.downloading`` sentinel left over from a previous 

140 process that got killed mid-download. 

141 

142 The sentinel is created inside ``download_journal_data`` and cleaned 

143 up in its ``finally`` block. If the process is SIGKILLed (or crashes 

144 hard enough that the ``finally`` doesn't run) the file sits on disk 

145 forever. Every subsequent call then sees "Download already in 

146 progress" and bows out, even though nothing is actually downloading. 

147 

148 Called once at import time. A fresh process can't possibly own an 

149 in-progress download, so any pre-existing sentinel is by definition 

150 an orphan. The 20-minute ``_SENTINEL_STALE_SECS`` recovery path 

151 still exists for the "process still alive but hung" case. 

152 

153 Swallows all exceptions — a misread on startup must not break the 

154 module. Tests that run concurrently in the same process 

155 (test_concurrent_download_blocked) set the sentinel deliberately 

156 *after* import, so this runs once and doesn't interfere. 

157 """ 

158 try: 

159 sentinel = _get_data_dir() / _SENTINEL 

160 if sentinel.exists(): 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true

161 sentinel.unlink() 

162 logger.warning( 

163 f"Cleared orphan {_SENTINEL} sentinel from a previous run " 

164 f"(the old process was killed mid-download; a fresh process " 

165 f"cannot own an in-progress download)." 

166 ) 

167 except Exception: 

168 logger.exception( 

169 "Could not clear orphan sentinel on startup; " 

170 "new downloads may be blocked until the 20-minute stale timer." 

171 ) 

172 

173 

174_clear_orphan_sentinel_on_startup() 

175 

176 

177# --------------------------------------------------------------------------- 

178# Test-patch shims for `_fetch_openalex_sources` and `_fetch_doaj_journals` 

179# 

180# Tests in `tests/journal_quality/test_downloader.py` and 

181# `tests/journal_quality/test_downloader_exception_sanitization.py` patch 

182# these names by string path. The bodies have moved into `OpenAlexSource.fetch` 

183# and `DOAJSource.fetch`, but we expose module-level wrappers so the 

184# existing patches keep intercepting calls. The bulk download loop below 

185# routes both sources through these wrappers (not directly through 

186# `.fetch()`) so that `mock.patch` substitutions take effect. 

187# --------------------------------------------------------------------------- 

188 

189 

190def _fetch_openalex_sources(data_dir: Path, progress_cb=None) -> int: 

191 return get_source("openalex").fetch(data_dir, progress_cb=progress_cb) 

192 

193 

194def _fetch_doaj_journals(data_dir: Path, progress_cb=None) -> int: 

195 return get_source("doaj").fetch(data_dir, progress_cb=progress_cb) 

196 

197 

198def _fetch_predatory(data_dir: Path, progress_cb=None) -> int: 

199 return get_source("predatory").fetch(data_dir, progress_cb=progress_cb) 

200 

201 

202def _fetch_jabref_abbreviations(data_dir: Path, progress_cb=None) -> int: 

203 return get_source("jabref").fetch(data_dir, progress_cb=progress_cb) 

204 

205 

206def _fetch_institutions(data_dir: Path, progress_cb=None) -> int: 

207 return get_source("institutions").fetch(data_dir, progress_cb=progress_cb) 

208 

209 

210# Map source key → module-level shim *name*, resolved at call time so 

211# `mock.patch` substitutions on the module attribute take effect (capturing 

212# function objects in a dict at import time would defeat patching). 

213_FETCH_SHIM_NAMES = { 

214 "openalex": "_fetch_openalex_sources", 

215 "doaj": "_fetch_doaj_journals", 

216 "predatory": "_fetch_predatory", 

217 "jabref": "_fetch_jabref_abbreviations", 

218 "institutions": "_fetch_institutions", 

219} 

220 

221 

222# --------------------------------------------------------------------------- 

223# Status 

224# --------------------------------------------------------------------------- 

225 

226 

227def get_journal_data_status() -> dict: 

228 """Return the status payload for the dashboard data sources banner. 

229 

230 Shape (kept stable for existing JS consumers and tests): 

231 { 

232 "available": bool, # OpenAlex source OR compiled DB present 

233 "version": Optional[str], 

234 "latest_version": str, 

235 "needs_update": bool, 

236 "files": dict[str, bool], # legacy: filename → present 

237 "sources": list[dict], # per-source detail for the banner 

238 "data_dir": str, 

239 } 

240 """ 

241 # Lazy import to avoid any downloader <-> db module-level cycle, 

242 # matching the existing db.py -> downloader.py lazy imports for this 

243 # same pair (`_warn_on_stale_data_version`, `_build_or_raise`). 

244 from .db import DB_FILENAME, journal_db_file_is_valid 

245 

246 data_dir = _get_data_dir() 

247 version_file = data_dir / "version.json" 

248 

249 installed_version: Optional[str] = None 

250 if version_file.exists(): 

251 try: 

252 with open(version_file, encoding="utf-8") as f: 

253 info = json.load(f) 

254 installed_version = info.get("version") 

255 except (json.JSONDecodeError, OSError): 

256 pass 

257 

258 files = {src.filename: src.is_present(data_dir) for src in ALL_SOURCES} 

259 sources = [src.status_dict(data_dir) for src in ALL_SOURCES] 

260 

261 # Available if the (required) OpenAlex source exists OR the compiled 

262 # reference DB exists AND is usable. The compiled DB is useful even 

263 # when the source JSON has been deleted, since the dashboard can 

264 # still query it — but a file that merely EXISTS while being 

265 # unreadable (stale schema, corrupt) must not count: every actual 

266 # read would rebuild it (or, under a forbidding egress scope, 

267 # refuse) instead of serving it, so reporting "available" here would 

268 # hide the install banner behind a file that answers with silent 

269 # zeros. `journal_reference.db` is the pre-rename legacy filename 

270 # the downloader below deletes after a fresh download; an install 

271 # that hasn't gone through one yet is checked the same way. The 

272 # `exists()` guard keeps an absent file (the usual case for the 

273 # legacy name, and for both on first run) a quiet "not present": 

274 # the validator logs an exception traceback on any open failure. 

275 has_source = files.get(REQUIRED_SNAPSHOT_FILENAME, False) 

276 has_db = any( 

277 path.exists() and journal_db_file_is_valid(path) 

278 for path in (data_dir / DB_FILENAME, data_dir / "journal_reference.db") 

279 ) 

280 

281 return { 

282 "available": has_source or has_db, 

283 "version": installed_version, 

284 "latest_version": JOURNAL_DATA_VERSION, 

285 # `needs_update` is True both when no version is installed at all 

286 # (first run — show the download CTA) and when the installed 

287 # version is older than the bundled latest. The previous `and` 

288 # spelling silently hid the first-run case from the dashboard. 

289 "needs_update": ( 

290 installed_version is None 

291 or installed_version != JOURNAL_DATA_VERSION 

292 ), 

293 "files": files, 

294 "sources": sources, 

295 "data_dir": str(data_dir), 

296 # Live progress for the dashboard's status indicator. The client 

297 # polls this endpoint while a download is in flight; see 

298 # downloadJournalData() in journal_quality.html. 

299 "download_progress": get_download_state(), 

300 } 

301 

302 

303# --------------------------------------------------------------------------- 

304# Bulk download (dashboard "Download Data" button) 

305# --------------------------------------------------------------------------- 

306 

307 

308def download_journal_data(force: bool = False) -> tuple[bool, str]: 

309 """Fetch every registered data source into the user data directory. 

310 

311 Iterates `ALL_SOURCES` in order. Sources marked `required=True` 

312 (OpenAlex) abort the batch on failure; `required=False` sources are 

313 best-effort and continue on error. 

314 

315 Args: 

316 force: Re-fetch even if data exists and is current version. 

317 

318 Returns: 

319 (success, message) tuple. Message format is 

320 `"Fetched <N1> <label1> + <N2> <label2> + ... in <S>s"` so the 

321 existing test substring assertions ("100 OpenAlex", "50 DOAJ") 

322 continue to match. 

323 """ 

324 data_dir = _get_data_dir() 

325 sentinel = data_dir / _SENTINEL 

326 

327 if not force: 

328 status = get_journal_data_status() 

329 if status["available"] and not status["needs_update"]: 

330 # No fresh fetch ran, so invalidate any counts cached from a 

331 # previous in-process download. Callers keying a "what just 

332 # happened" summary off `counts` must see None here. 

333 _download_state["counts"] = None 

334 return True, "Journal data is already up to date" 

335 

336 # Disk-space pre-check. The five data sources uncompress to ~1 GB 

337 # intermediate, plus the compiled DB. Fail fast with a clear message 

338 # rather than crashing mid-download and leaving a corrupt tmp file. 

339 import shutil as _shutil 

340 

341 from ..constants import JOURNAL_QUALITY_MIN_FREE_DISK_BYTES 

342 

343 try: 

344 free_bytes = _shutil.disk_usage(str(data_dir)).free 

345 except OSError: 

346 logger.warning( 

347 f"Could not check free disk space for {data_dir}; proceeding." 

348 ) 

349 free_bytes = None 

350 if ( 

351 free_bytes is not None 

352 and free_bytes < JOURNAL_QUALITY_MIN_FREE_DISK_BYTES 

353 ): 

354 # No fetch ran → invalidate any stale counts from a prior call 

355 # so `get_download_state()["counts"]` cannot leak them. 

356 _download_state["counts"] = None 

357 return False, ( 

358 f"Insufficient disk space: " 

359 f"{free_bytes / (1024**3):.1f} GB available, " 

360 f"{JOURNAL_QUALITY_MIN_FREE_DISK_BYTES / (1024**3):.0f} GB required." 

361 ) 

362 

363 # Atomic sentinel creation (O_CREAT | O_EXCL). Replaces the previous 

364 # exists()+touch() TOCTOU race so two concurrent download triggers 

365 # (dashboard click + scheduler) cannot both proceed. 

366 # 

367 # Stale-sentinel recovery. Two triggers are checked each call: 

368 # 

369 # 1. PID-based: the sentinel holds the PID of the process that 

370 # created it. If that PID is not alive, the owner process 

371 # crashed or was killed and the sentinel is orphan. 

372 # 2. Age-based (fallback): if the sentinel is older than 

373 # _SENTINEL_STALE_SECS we reclaim it even if the PID check is 

374 # inconclusive (exotic environments, unreadable sentinel). 

375 # 

376 # The startup hook in _clear_orphan_sentinel_on_startup handles the 

377 # common case of "server was restarted mid-download"; these two 

378 # runtime checks cover the case where the server is still running 

379 # but the download worker thread itself crashed out of the sentinel. 

380 

381 def _sentinel_owner_alive() -> bool: 

382 try: 

383 owner_pid = int(sentinel.read_text(encoding="utf-8").strip()) 

384 except (OSError, ValueError): 

385 return False # unreadable / malformed → treat as orphan 

386 if owner_pid == os.getpid(): 386 ↛ 391line 386 didn't jump to line 391 because the condition on line 386 was always true

387 # Same process. Something is wrong (we should never race 

388 # with ourselves — the module-level lock guards that) but 

389 # err on the side of "alive" to avoid self-nuking. 

390 return True 

391 try: 

392 os.kill(owner_pid, 0) # signal 0 = liveness probe 

393 except ProcessLookupError: 

394 return False 

395 except PermissionError: 

396 # Process exists, owned by another user — treat as alive. 

397 return True 

398 except OSError: 

399 return False 

400 return True 

401 

402 def _try_claim_sentinel() -> bool: 

403 """Create the sentinel + stamp our PID. Returns True on success.""" 

404 try: 

405 with sentinel.open("x", encoding="utf-8") as f: 

406 f.write(str(os.getpid())) 

407 return True 

408 except FileExistsError: 

409 return False 

410 

411 if not _try_claim_sentinel(): 

412 try: 

413 age = time.time() - sentinel.stat().st_mtime 

414 except OSError: 

415 age = 0 

416 orphan = not _sentinel_owner_alive() 

417 if orphan or age > _SENTINEL_STALE_SECS: 

418 reason = ( 

419 "owner process not alive" 

420 if orphan 

421 else f"age {age:.0f}s > {_SENTINEL_STALE_SECS}s" 

422 ) 

423 logger.warning( 

424 f"Reclaiming stale .downloading sentinel ({reason}); " 

425 "previous download likely crashed without cleanup." 

426 ) 

427 sentinel.unlink(missing_ok=True) 

428 if not _try_claim_sentinel(): 428 ↛ 431line 428 didn't jump to line 431 because the condition on line 428 was never true

429 # Lost a race with another caller reclaiming the same 

430 # stale sentinel — bow out cleanly. 

431 _download_state["counts"] = None 

432 return False, "Download already in progress" 

433 else: 

434 _download_state["counts"] = None 

435 return False, "Download already in progress" 

436 try: 

437 start = time.time() 

438 counts: dict[str, int] = {} 

439 parts: list[str] = [] 

440 

441 # Reset per-source state to a clean "pending" row for each 

442 # known source. The dashboard renders one row per entry. 

443 _download_state["state"] = "running" 

444 _download_state["started_at"] = start 

445 _download_state["finished_at"] = None 

446 _download_state["error_msg"] = None 

447 # Invalidate counts from any prior run — callers inspecting the 

448 # structured summary must not see stale data if this download 

449 # fails or is still running. 

450 _download_state["counts"] = None 

451 _download_state["db_build"] = {"state": "pending", "detail": ""} 

452 _download_state["sources"] = { 

453 src.key: { 

454 "name": src.name, 

455 "state": "pending", 

456 "detail": "", 

457 "percent": 0, 

458 "count": 0, 

459 "required": src.required, 

460 } 

461 for src in ALL_SOURCES 

462 } 

463 

464 def _fetch_one(src): 

465 """Worker: run one source's fetch, mirror state as it goes.""" 

466 _set_source_state( 

467 src.key, state="running", detail="downloading", percent=5 

468 ) 

469 

470 # Per-partition callback: the chunked sources (openalex 

471 # sources + institutions) call this on every partition so 

472 # the dashboard's bar moves smoothly. One-shot sources 

473 # don't call it and stay at the initial 5% → final 100%. 

474 def _on_progress(done, total, detail): 

475 pct = int(5 + (done / total) * 90) if total > 0 else 5 

476 _set_source_state( 

477 src.key, 

478 state="running", 

479 detail=detail, 

480 percent=max(5, min(95, pct)), 

481 ) 

482 

483 try: 

484 shim_name = _FETCH_SHIM_NAMES.get(src.key) 

485 if shim_name: 

486 # bearer:disable python_lang_code_injection 

487 # _FETCH_SHIM_NAMES is a hardcoded dict (line 188); the 

488 # late-bound globals() lookup is needed for mock.patch 

489 # compatibility in tests. No user input reaches the key. 

490 n = globals()[shim_name](data_dir, progress_cb=_on_progress) 

491 else: 

492 n = src.fetch(data_dir, progress_cb=_on_progress) 

493 _set_source_state( 

494 src.key, 

495 state="success", 

496 detail=f"{n} {src.count_label}", 

497 percent=100, 

498 count=n, 

499 ) 

500 return (src, n, None) 

501 except Exception as exc: 

502 logger.exception( 

503 f"{src.name} fetch failed " 

504 f"({'required' if src.required else 'optional'})" 

505 ) 

506 _set_source_state( 

507 src.key, 

508 state="error", 

509 detail=exc.__class__.__name__, 

510 percent=100, 

511 ) 

512 return (src, 0, exc) 

513 

514 # Parallel fetch — every source streams from a different host 

515 # (openalex S3, DOAJ CSV, raw.githubusercontent.com, api.openalex.org 

516 # for institutions). No single-host contention; the wall-clock is 

517 # dominated by the slowest source (OpenAlex snapshot ~30-60 s). 

518 from concurrent.futures import ThreadPoolExecutor 

519 

520 with ThreadPoolExecutor( 

521 max_workers=max(1, len(ALL_SOURCES)), 

522 thread_name_prefix="journal-dl", 

523 ) as pool: 

524 results = list(pool.map(_fetch_one, ALL_SOURCES)) 

525 

526 required_failure = None 

527 for src, n, exc in results: 

528 counts[src.key] = n 

529 parts.append(f"{n} {src.count_label}") 

530 if exc is not None and src.required: 

531 required_failure = (src, exc) 

532 

533 if required_failure is not None: 

534 src, _exc = required_failure 

535 msg = f"Failed to fetch {src.name}. Check your network connection." 

536 _download_state["state"] = "error" 

537 _download_state["error_msg"] = msg 

538 _download_state["finished_at"] = time.time() 

539 return False, msg 

540 

541 # Write version marker. Per-source key names are preserved for 

542 # any external consumer that might read them, even though no 

543 # production code does today. 

544 version_file = data_dir / "version.json" 

545 with open(version_file, "w", encoding="utf-8") as f: 

546 json.dump( 

547 { 

548 "version": JOURNAL_DATA_VERSION, 

549 "downloaded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"), 

550 "openalex_count": counts.get("openalex", 0), 

551 "doaj_count": counts.get("doaj", 0), 

552 "jabref_count": counts.get("jabref", 0), 

553 "predatory_count": counts.get("predatory", 0), 

554 }, 

555 f, 

556 ) 

557 

558 # Rebuild journal_quality.db synchronously from the freshly 

559 # downloaded gz files. The build is the ONLY writer of this 

560 # file; everything else opens it read-only via mode=ro. 

561 # Also clean up any leftover legacy journal_reference.db files 

562 # from before the rename so existing installs don't carry junk. 

563 legacy_db = data_dir / "journal_reference.db" 

564 if legacy_db.exists(): 564 ↛ 565line 564 didn't jump to line 565 because the condition on line 564 was never true

565 try: 

566 import os as _os 

567 

568 # bearer:disable python_lang_file_permissions 

569 _os.chmod(legacy_db, 0o644) 

570 legacy_db.unlink() 

571 logger.info( 

572 "Removed legacy journal_reference.db " 

573 "(replaced by journal_quality.db)" 

574 ) 

575 except OSError: 

576 logger.exception("Could not remove legacy DB") 

577 

578 _download_state["db_build"] = { 

579 "state": "running", 

580 "detail": "parsing bundled data", 

581 } 

582 db_build_error: Optional[str] = None 

583 try: 

584 from .db import DB_FILENAME, build_db 

585 

586 new_db_file = data_dir / DB_FILENAME 

587 if new_db_file.exists(): 587 ↛ 588line 587 didn't jump to line 588 because the condition on line 587 was never true

588 import os as _os 

589 

590 # bearer:disable python_lang_file_permissions 

591 _os.chmod(new_db_file, 0o644) 

592 new_db_file.unlink() 

593 build_db(data_dir=data_dir, output_path=new_db_file) 

594 except Exception as exc: 

595 logger.exception( 

596 "Failed to rebuild journal_quality.db; " 

597 "the runtime accessor will lazy-build on next access" 

598 ) 

599 # SchemaDriftError messages are developer-authored literals 

600 # (no SQL, paths, or stack fragments) so they're safe to 

601 # surface — operators need to see *which* field drifted to 

602 # act on it. For any other exception, fall back to the 

603 # class name only, per CodeQL "Information exposure through 

604 # an exception" (alerts 7650, 7684). The full trace always 

605 # stays in logger.exception above (server-side only). 

606 from .data_sources.openalex import SchemaDriftError 

607 

608 if isinstance(exc, SchemaDriftError): 

609 db_build_error = str(exc) 

610 else: 

611 db_build_error = exc.__class__.__name__ 

612 

613 elapsed = time.time() - start 

614 if db_build_error: 

615 msg = ( 

616 f"Downloaded data ({' + '.join(parts)}) in {elapsed:.0f}s " 

617 f"but DB build failed ({db_build_error}). " 

618 f"Lazy-build will retry on next access." 

619 ) 

620 _download_state["db_build"] = { 

621 "state": "error", 

622 "detail": db_build_error, 

623 } 

624 _download_state["state"] = "error" 

625 _download_state["error_msg"] = msg 

626 _download_state["finished_at"] = time.time() 

627 return False, msg 

628 

629 success_msg = f"Fetched {' + '.join(parts)} in {elapsed:.0f}s" 

630 _download_state["db_build"] = { 

631 "state": "success", 

632 "detail": "ready", 

633 } 

634 _download_state["state"] = "success" 

635 _download_state["error_msg"] = None 

636 _download_state["finished_at"] = time.time() 

637 # Publish structured counts for callers that want to render a 

638 # user-facing summary without echoing `success_msg`. All values 

639 # are ints populated from source `.fetch()` returns above. 

640 _download_state["counts"] = dict(counts) 

641 return True, success_msg 

642 

643 finally: 

644 sentinel.unlink(missing_ok=True) 

645 

646 

647_ensure_cache: Optional[tuple[float, tuple[Optional[Path], bool]]] = None 

648_ENSURE_CACHE_TTL = 30.0 # seconds 

649 

650 

651def ensure_journal_data( 

652 auto_download: bool = True, 

653) -> tuple[Optional[Path], bool]: 

654 """Ensure journal data is available, optionally triggering a bulk fetch. 

655 

656 Returns: 

657 (data_dir, is_available) — data_dir is None if unavailable. 

658 

659 Thundering-herd guard: when a search runs, every search engine's 

660 reputation-filter worker (~30 threads) calls this concurrently. 

661 Without the cache, 29 of them race to create the sentinel and 

662 each logs a WARNING. One call does the real work; the rest get 

663 the cached answer for 30 seconds. The success path (data files 

664 present) is already fast — we only cache the negative / race 

665 result, which is the noisy path. 

666 

667 A caller that is not allowed to download never writes the cache: 

668 it attempted nothing worth memoising, and the entry carries no 

669 record of which question it answered, so it would go on to answer 

670 callers that ARE allowed to download. 

671 """ 

672 global _ensure_cache 

673 

674 user_dir = _get_data_dir() 

675 if (user_dir / REQUIRED_SNAPSHOT_FILENAME).exists(): 

676 # Positive path is cheap (one stat call) — no need to cache. 

677 return user_dir, True 

678 

679 now = time.time() 

680 if _ensure_cache is not None: 

681 ts, cached = _ensure_cache 

682 if now - ts < _ENSURE_CACHE_TTL: 682 ↛ 685line 682 didn't jump to line 685 because the condition on line 682 was always true

683 return cached 

684 

685 if not auto_download: 

686 # Refusals (test mock mode, or a lazy build whose egress scope 

687 # forbids the public fetch) deliberately do NOT populate the 

688 # cache. The cached tuple records no trace of which question it 

689 # answered, so caching this one would answer the next 30 seconds 

690 # of ensure_journal_data(auto_download=True) calls — another 

691 # user's search, whose scope permits the fetch — with a stale 

692 # "unavailable" and silently suppress a legitimate download. 

693 # Nothing is lost: this branch performed a single stat, not the 

694 # download/sentinel race the cache exists to absorb. 

695 return None, False 

696 

697 logger.info("Journal data not found — fetching from upstream sources...") 

698 success, message = download_journal_data() 

699 if success: 

700 logger.info(message) 

701 _ensure_cache = (now, (user_dir, True)) 

702 return user_dir, True 

703 logger.warning(f"Journal data fetch failed: {message}") 

704 

705 result: tuple[Optional[Path], bool] = (None, False) 

706 _ensure_cache = (now, result) 

707 return result