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

223 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +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- `get_journal_data_status()` 

13- `download_journal_data(force=False)` 

14- `ensure_journal_data(auto_download=True)` 

15 

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

17- `_get_data_dir` 

18- `_fetch_openalex_sources` (re-export shim) 

19- `_fetch_doaj_journals` (re-export shim) 

20 

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

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

23""" 

24 

25from __future__ import annotations 

26 

27import json 

28import os 

29import time 

30from pathlib import Path 

31from typing import Optional 

32 

33from loguru import logger 

34 

35from .data_sources import ALL_SOURCES, get_source 

36 

37JOURNAL_DATA_VERSION = "v4" 

38 

39_SENTINEL = ".downloading" 

40 

41 

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

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

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

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

46_SENTINEL_STALE_SECS = 20 * 60 

47 

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

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

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

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

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

53# started_at: epoch seconds, or None 

54# finished_at: epoch seconds, or None 

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

56# db_build: {state, detail} 

57# error_msg: str or None 

58# 

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

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

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

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

63_download_state: dict = { 

64 "state": "idle", 

65 "started_at": None, 

66 "finished_at": None, 

67 "sources": {}, 

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

69 "error_msg": None, 

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

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

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

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

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

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

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

77 # string. 

78 "counts": None, 

79} 

80 

81 

82def get_download_state() -> dict: 

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

84 

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

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

87 We copy the nested dicts too. 

88 """ 

89 counts = _download_state["counts"] 

90 return { 

91 "state": _download_state["state"], 

92 "started_at": _download_state["started_at"], 

93 "finished_at": _download_state["finished_at"], 

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

95 "error_msg": _download_state["error_msg"], 

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

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

98 } 

99 

100 

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

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

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

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

105 """ 

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

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

108 ) 

109 entry.update(updates) 

110 logger.info( 

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

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

113 ) 

114 

115 

116def _get_data_dir() -> Path: 

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

118 

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

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

121 """ 

122 from ..config.paths import get_journal_data_directory 

123 

124 return get_journal_data_directory() 

125 

126 

127def _clear_orphan_sentinel_on_startup() -> None: 

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

129 process that got killed mid-download. 

130 

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

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

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

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

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

136 

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

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

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

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

141 

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

143 module. Tests that run concurrently in the same process 

144 (test_concurrent_download_blocked) set the sentinel deliberately 

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

146 """ 

147 try: 

148 sentinel = _get_data_dir() / _SENTINEL 

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

150 sentinel.unlink() 

151 logger.warning( 

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

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

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

155 ) 

156 except Exception: 

157 logger.exception( 

158 "Could not clear orphan sentinel on startup; " 

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

160 ) 

161 

162 

163_clear_orphan_sentinel_on_startup() 

164 

165 

166# --------------------------------------------------------------------------- 

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

168# 

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

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

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

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

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

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

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

176# --------------------------------------------------------------------------- 

177 

178 

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

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

181 

182 

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

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

185 

186 

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

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

189 

190 

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

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

193 

194 

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

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

197 

198 

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

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

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

202_FETCH_SHIM_NAMES = { 

203 "openalex": "_fetch_openalex_sources", 

204 "doaj": "_fetch_doaj_journals", 

205 "predatory": "_fetch_predatory", 

206 "jabref": "_fetch_jabref_abbreviations", 

207 "institutions": "_fetch_institutions", 

208} 

209 

210 

211# --------------------------------------------------------------------------- 

212# Status 

213# --------------------------------------------------------------------------- 

214 

215 

216def get_journal_data_status() -> dict: 

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

218 

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

220 { 

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

222 "version": Optional[str], 

223 "latest_version": str, 

224 "needs_update": bool, 

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

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

227 "data_dir": str, 

228 } 

229 """ 

230 data_dir = _get_data_dir() 

231 version_file = data_dir / "version.json" 

232 

233 installed_version: Optional[str] = None 

234 if version_file.exists(): 

235 try: 

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

237 info = json.load(f) 

238 installed_version = info.get("version") 

239 except (json.JSONDecodeError, OSError): 

240 pass 

241 

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

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

244 

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

246 # reference DB exists. The compiled DB is useful even when the source 

247 # JSON has been deleted, since the dashboard can still query it. 

248 has_source = files.get("openalex_sources.json.gz", False) 

249 has_db = (data_dir / "journal_quality.db").exists() or ( 

250 data_dir / "journal_reference.db" 

251 ).exists() 

252 

253 return { 

254 "available": has_source or has_db, 

255 "version": installed_version, 

256 "latest_version": JOURNAL_DATA_VERSION, 

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

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

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

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

261 "needs_update": ( 

262 installed_version is None 

263 or installed_version != JOURNAL_DATA_VERSION 

264 ), 

265 "files": files, 

266 "sources": sources, 

267 "data_dir": str(data_dir), 

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

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

270 # downloadJournalData() in journal_quality.html. 

271 "download_progress": get_download_state(), 

272 } 

273 

274 

275# --------------------------------------------------------------------------- 

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

277# --------------------------------------------------------------------------- 

278 

279 

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

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

282 

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

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

285 best-effort and continue on error. 

286 

287 Args: 

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

289 

290 Returns: 

291 (success, message) tuple. Message format is 

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

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

294 continue to match. 

295 """ 

296 data_dir = _get_data_dir() 

297 sentinel = data_dir / _SENTINEL 

298 

299 if not force: 

300 status = get_journal_data_status() 

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

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

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

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

305 _download_state["counts"] = None 

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

307 

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

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

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

311 import shutil as _shutil 

312 

313 from ..constants import JOURNAL_QUALITY_MIN_FREE_DISK_BYTES 

314 

315 try: 

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

317 except OSError: 

318 logger.warning( 

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

320 ) 

321 free_bytes = None 

322 if ( 

323 free_bytes is not None 

324 and free_bytes < JOURNAL_QUALITY_MIN_FREE_DISK_BYTES 

325 ): 

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

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

328 _download_state["counts"] = None 

329 return False, ( 

330 f"Insufficient disk space: " 

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

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

333 ) 

334 

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

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

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

338 # 

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

340 # 

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

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

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

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

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

346 # inconclusive (exotic environments, unreadable sentinel). 

347 # 

348 # The startup hook in _clear_orphan_sentinel_on_startup handles the 

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

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

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

352 

353 def _sentinel_owner_alive() -> bool: 

354 try: 

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

356 except (OSError, ValueError): 

357 return False # unreadable / malformed → treat as orphan 

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

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

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

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

362 return True 

363 try: 

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

365 except ProcessLookupError: 

366 return False 

367 except PermissionError: 

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

369 return True 

370 except OSError: 

371 return False 

372 return True 

373 

374 def _try_claim_sentinel() -> bool: 

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

376 try: 

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

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

379 return True 

380 except FileExistsError: 

381 return False 

382 

383 if not _try_claim_sentinel(): 

384 try: 

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

386 except OSError: 

387 age = 0 

388 orphan = not _sentinel_owner_alive() 

389 if orphan or age > _SENTINEL_STALE_SECS: 389 ↛ 390line 389 didn't jump to line 390 because the condition on line 389 was never true

390 reason = ( 

391 "owner process not alive" 

392 if orphan 

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

394 ) 

395 logger.warning( 

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

397 "previous download likely crashed without cleanup." 

398 ) 

399 sentinel.unlink(missing_ok=True) 

400 if not _try_claim_sentinel(): 

401 # Lost a race with another caller reclaiming the same 

402 # stale sentinel — bow out cleanly. 

403 _download_state["counts"] = None 

404 return False, "Download already in progress" 

405 else: 

406 _download_state["counts"] = None 

407 return False, "Download already in progress" 

408 try: 

409 start = time.time() 

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

411 parts: list[str] = [] 

412 

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

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

415 _download_state["state"] = "running" 

416 _download_state["started_at"] = start 

417 _download_state["finished_at"] = None 

418 _download_state["error_msg"] = None 

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

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

421 # fails or is still running. 

422 _download_state["counts"] = None 

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

424 _download_state["sources"] = { 

425 src.key: { 

426 "name": src.name, 

427 "state": "pending", 

428 "detail": "", 

429 "percent": 0, 

430 "count": 0, 

431 "required": src.required, 

432 } 

433 for src in ALL_SOURCES 

434 } 

435 

436 def _fetch_one(src): 

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

438 _set_source_state( 

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

440 ) 

441 

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

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

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

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

446 def _on_progress(done, total, detail): 

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

448 _set_source_state( 

449 src.key, 

450 state="running", 

451 detail=detail, 

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

453 ) 

454 

455 try: 

456 shim_name = _FETCH_SHIM_NAMES.get(src.key) 

457 if shim_name: 457 ↛ 464line 457 didn't jump to line 464 because the condition on line 457 was always true

458 # bearer:disable python_lang_code_injection 

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

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

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

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

463 else: 

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

465 _set_source_state( 

466 src.key, 

467 state="success", 

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

469 percent=100, 

470 count=n, 

471 ) 

472 return (src, n, None) 

473 except Exception as exc: 

474 logger.exception( 

475 f"{src.name} fetch failed " 

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

477 ) 

478 _set_source_state( 

479 src.key, 

480 state="error", 

481 detail=exc.__class__.__name__, 

482 percent=100, 

483 ) 

484 return (src, 0, exc) 

485 

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

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

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

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

490 from concurrent.futures import ThreadPoolExecutor 

491 

492 with ThreadPoolExecutor( 

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

494 thread_name_prefix="journal-dl", 

495 ) as pool: 

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

497 

498 required_failure = None 

499 for src, n, exc in results: 

500 counts[src.key] = n 

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

502 if exc is not None and src.required: 

503 required_failure = (src, exc) 

504 

505 if required_failure is not None: 

506 src, _exc = required_failure 

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

508 _download_state["state"] = "error" 

509 _download_state["error_msg"] = msg 

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

511 return False, msg 

512 

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

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

515 # production code does today. 

516 version_file = data_dir / "version.json" 

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

518 json.dump( 

519 { 

520 "version": JOURNAL_DATA_VERSION, 

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

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

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

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

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

526 }, 

527 f, 

528 ) 

529 

530 # Rebuild journal_quality.db synchronously from the freshly 

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

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

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

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

535 legacy_db = data_dir / "journal_reference.db" 

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

537 try: 

538 import os as _os 

539 

540 # bearer:disable python_lang_file_permissions 

541 _os.chmod(legacy_db, 0o644) 

542 legacy_db.unlink() 

543 logger.info( 

544 "Removed legacy journal_reference.db " 

545 "(replaced by journal_quality.db)" 

546 ) 

547 except OSError: 

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

549 

550 _download_state["db_build"] = { 

551 "state": "running", 

552 "detail": "parsing bundled data", 

553 } 

554 db_build_error: Optional[str] = None 

555 try: 

556 from .db import DB_FILENAME, build_db 

557 

558 new_db_file = data_dir / DB_FILENAME 

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

560 import os as _os 

561 

562 # bearer:disable python_lang_file_permissions 

563 _os.chmod(new_db_file, 0o644) 

564 new_db_file.unlink() 

565 build_db(data_dir=data_dir, output_path=new_db_file) 

566 except Exception as exc: 

567 logger.exception( 

568 "Failed to rebuild journal_quality.db; " 

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

570 ) 

571 # SchemaDriftError messages are developer-authored literals 

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

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

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

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

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

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

578 from .data_sources.openalex import SchemaDriftError 

579 

580 if isinstance(exc, SchemaDriftError): 

581 db_build_error = str(exc) 

582 else: 

583 db_build_error = exc.__class__.__name__ 

584 

585 elapsed = time.time() - start 

586 if db_build_error: 

587 msg = ( 

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

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

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

591 ) 

592 _download_state["db_build"] = { 

593 "state": "error", 

594 "detail": db_build_error, 

595 } 

596 _download_state["state"] = "error" 

597 _download_state["error_msg"] = msg 

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

599 return False, msg 

600 

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

602 _download_state["db_build"] = { 

603 "state": "success", 

604 "detail": "ready", 

605 } 

606 _download_state["state"] = "success" 

607 _download_state["error_msg"] = None 

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

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

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

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

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

613 return True, success_msg 

614 

615 finally: 

616 sentinel.unlink(missing_ok=True) 

617 

618 

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

620_ENSURE_CACHE_TTL = 30.0 # seconds 

621 

622 

623def ensure_journal_data( 

624 auto_download: bool = True, 

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

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

627 

628 Returns: 

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

630 

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

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

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

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

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

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

637 result, which is the noisy path. 

638 """ 

639 global _ensure_cache 

640 

641 user_dir = _get_data_dir() 

642 if (user_dir / "openalex_sources.json.gz").exists(): 

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

644 return user_dir, True 

645 

646 now = time.time() 

647 if _ensure_cache is not None: 

648 ts, cached = _ensure_cache 

649 if now - ts < _ENSURE_CACHE_TTL: 

650 return cached 

651 

652 if auto_download: 

653 logger.info( 

654 "Journal data not found — fetching from upstream sources..." 

655 ) 

656 success, message = download_journal_data() 

657 if success: 657 ↛ 658line 657 didn't jump to line 658 because the condition on line 657 was never true

658 logger.info(message) 

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

660 return user_dir, True 

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

662 

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

664 _ensure_cache = (now, result) 

665 return result