""" Everything related to talking to the Hugging Face dataset that acts as our "database": checking whether a plot was already generated, queueing a new Plotly figure for the next batched upload, and keeping a small catalog/index file per PDF set so lookups don't require listing the whole repo. Plots are NOT uploaded one at a time. Hugging Face caps commits at 128/hour per repo, and the old immediate-upload path spent two commits per plot (the plot file + the index.json update) — a handful of dashboard visitors could exhaust that budget in minutes. Instead, `queue_plot` writes the figure to local scratch space and registers it in memory; a background job (started in app.py) periodically calls `flush_pending`, which bundles every queued plot across every grid into a SINGLE `create_commit` call. Until a flush happens, generated plots are served directly off local disk by app.py's `/plots/{folder}/{filename}` route, so callers see the plot immediately regardless of commit timing. Dataset layout (inside `dataset_repo`): / <- LHAPDF grid files (already existing) plots/ index.json <- catalog of every plot generated for this grid _plt_xfx_PID_Q.json <- standard plot _plt_xfx_ratio_PID_Q.json <- ratio plot _plt_xfx_corr_PID_PID_Q.json <- correlation plot """ import hashlib import json import logging import os import threading import time from datetime import datetime, timezone from typing import Optional from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download from huggingface_hub.utils import EntryNotFoundError, RepositoryNotFoundError from services.config import SPACE_BASE_URL, TMP_DIR, index_path_in_repo, plots_folder logger = logging.getLogger(__name__) _lock = threading.Lock() # plot_id -> index entry, for plots generated but not yet committed to HF. _pending_entries: dict = {} # "dataset_repo|grid_name" -> list of plot_ids queued for that grid, so a # flush can group per-grid index.json updates without scanning everything. _pending_by_grid: dict = {} def _grid_key(dataset_repo: str, grid_name: str) -> str: return f"{dataset_repo}|{grid_name}" def make_plot_id(plot_type: str, grid_name: str, parameters: dict) -> str: """ Deterministic id for a plot request: same plot_type + grid_name + parameters always hashes to the same id, which is how we detect an already-generated plot regardless of key ordering. Used only for cache lookups in index.json — the actual filename stored in the dataset follows the human-readable convention in services.config. """ payload = json.dumps( {"plot_type": plot_type, "grid_name": grid_name, "parameters": parameters}, sort_keys=True, ) digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] return f"{plot_type}_{digest}" def _local_plot_path(path_in_repo: str) -> str: return os.path.join(TMP_DIR, path_in_repo) def _load_index(dataset_repo: str, grid_name: str, hf_token: str) -> list: """Download and parse index.json for a grid. Returns [] if it doesn't exist yet.""" try: local_path = hf_hub_download( repo_id=dataset_repo, repo_type="dataset", filename=index_path_in_repo(grid_name), token=hf_token, ) except (EntryNotFoundError, RepositoryNotFoundError, Exception): # Covers "file not found" as well as "folder doesn't exist yet". return [] with open(local_path, "r") as f: return json.load(f) def find_cached_plot(dataset_repo: str, grid_name: str, plot_id: str, hf_token: str) -> Optional[dict]: """Look up a plot that's already been committed to the HF dataset's index.json.""" index = _load_index(dataset_repo, grid_name, hf_token) for entry in index: if entry.get("plot_id") == plot_id: logger.info("plot_id=%s found in committed index.json for %s/%s", plot_id, dataset_repo, grid_name) return entry logger.info("plot_id=%s not in committed index.json for %s/%s (%d entries checked)", plot_id, dataset_repo, grid_name, len(index)) return None def find_pending_plot(plot_id: str) -> Optional[dict]: """ Look up a plot generated earlier in the current batching window but not yet flushed to HF. Checked before recomputing so repeat requests for the same plot (e.g. a page reload) don't waste compute or queue duplicate uploads while waiting for the next flush. """ with _lock: entry = _pending_entries.get(plot_id) logger.info("plot_id=%s %s in pending (not-yet-flushed) entries", plot_id, "found" if entry else "not found") return entry def queue_plot( dataset_repo: str, grid_name: str, plot_id: str, plot_type: str, parameters: dict, fig, json_filename: str, ) -> dict: """ Write a Plotly figure to local scratch space and register it for the next batched commit (see flush_pending). Returns the same shape as the old immediate-upload path so callers don't need to change, except plotly_json_url points at this Space's own /plots route (served straight off local disk) rather than the HF resolve URL, since the file may not be committed yet. """ path_in_repo = f"{plots_folder(grid_name)}/{json_filename}" local_path = _local_plot_path(path_in_repo) os.makedirs(os.path.dirname(local_path), exist_ok=True) with open(local_path, "w") as f: f.write(fig.to_json()) entry = { "plot_id": plot_id, "plot_type": plot_type, "grid_name": grid_name, "parameters": parameters, "json_filename": json_filename, "json_path": path_in_repo, "dataset_repo": dataset_repo, "plot_url": f"https://huggingface.co/datasets/{dataset_repo}/blob/main/{path_in_repo}", "plotly_json_url": f"{SPACE_BASE_URL}/plots/{path_in_repo}", "created_at": datetime.now(timezone.utc).isoformat(), } key = _grid_key(dataset_repo, grid_name) with _lock: _pending_entries[plot_id] = entry _pending_by_grid.setdefault(key, []) if plot_id not in _pending_by_grid[key]: _pending_by_grid[key].append(plot_id) pending_count = len(_pending_by_grid[key]) logger.info("queued plot_id=%s (%s) for %s -- %d plot(s) pending flush for this grid", plot_id, json_filename, key, pending_count) return entry def pending_status() -> dict: """Number of not-yet-flushed plots queued per grid, for the /pending-status endpoint.""" with _lock: return {key: len(ids) for key, ids in _pending_by_grid.items()} def flush_pending(hf_token: str) -> dict: """ Bundle every queued plot (across every grid/dataset_repo) into ONE `create_commit` call per dataset_repo: all plot JSON files plus each affected grid's merged index.json. This is what keeps the Space under HF's per-repo commit rate limit regardless of how many plots were generated during the window. """ with _lock: if not _pending_entries: logger.info("flush_pending: nothing queued, skipping") return {"flushed": 0, "grids": []} entries_by_plot_id = dict(_pending_entries) by_grid = {key: list(ids) for key, ids in _pending_by_grid.items()} logger.info("flush_pending: starting flush of %d plot(s) across %d grid(s)", len(entries_by_plot_id), len(by_grid)) # Group by dataset_repo since create_commit operates on a single repo. operations_by_repo: dict = {} flushed_plot_ids = [] flushed_grid_keys = [] for key, plot_ids in by_grid.items(): dataset_repo, grid_name = key.split("|", 1) pending_for_grid = [entries_by_plot_id[pid] for pid in plot_ids if pid in entries_by_plot_id] if not pending_for_grid: continue operations = operations_by_repo.setdefault(dataset_repo, []) for entry in pending_for_grid: local_path = _local_plot_path(entry["json_path"]) if os.path.isfile(local_path): operations.append( CommitOperationAdd(path_in_repo=entry["json_path"], path_or_fileobj=local_path) ) existing_index = _load_index(dataset_repo, grid_name, hf_token) merged = [e for e in existing_index if e.get("plot_id") not in plot_ids] merged.extend( {k: v for k, v in e.items() if k != "dataset_repo"} for e in pending_for_grid ) local_index_path = os.path.join(TMP_DIR, f"_flush_index_{dataset_repo.replace('/', '_')}_{grid_name}.json") with open(local_index_path, "w") as f: json.dump(merged, f, indent=2) operations.append( CommitOperationAdd(path_in_repo=index_path_in_repo(grid_name), path_or_fileobj=local_index_path) ) flushed_plot_ids.extend(plot_ids) flushed_grid_keys.append(key) if not any(operations_by_repo.values()): logger.info("flush_pending: no operations to commit after grouping, skipping") return {"flushed": 0, "grids": []} api = HfApi(token=hf_token) for dataset_repo, operations in operations_by_repo.items(): if not operations: continue logger.info("flush_pending: committing %d operation(s) to %s ...", len(operations), dataset_repo) t0 = time.monotonic() api.create_commit( repo_id=dataset_repo, repo_type="dataset", operations=operations, commit_message=f"Batch upload of {len(operations)} file(s)", ) logger.info("flush_pending: committed to %s in %.2fs", dataset_repo, time.monotonic() - t0) # Only after every commit above has actually succeeded: delete the local # copies of everything just uploaded. Local disk on the Space container # is scratch space, not the source of truth (the HF dataset is), so # there's no reason to keep these around once they're safely committed — # they'd otherwise accumulate forever. Plots not yet flushed are left # alone (still needed for local-serving and repeat-request dedup); the # per-grid temp index snapshot files are always safe to remove, flushed # or not, since they're rebuilt fresh on every flush. for key in flushed_grid_keys: for pid in by_grid.get(key, []): entry = entries_by_plot_id.get(pid) if not entry: continue local_path = _local_plot_path(entry["json_path"]) try: os.remove(local_path) except OSError: pass # already gone, or never existed — nothing to clean up for dataset_repo in operations_by_repo: for key in flushed_grid_keys: repo_of_key, grid_name = key.split("|", 1) if repo_of_key != dataset_repo: continue local_index_path = os.path.join(TMP_DIR, f"_flush_index_{dataset_repo.replace('/', '_')}_{grid_name}.json") try: os.remove(local_index_path) except OSError: pass with _lock: for pid in flushed_plot_ids: _pending_entries.pop(pid, None) for key in flushed_grid_keys: remaining = [pid for pid in _pending_by_grid.get(key, []) if pid not in flushed_plot_ids] if remaining: _pending_by_grid[key] = remaining else: _pending_by_grid.pop(key, None) logger.info("flush_pending: done -- flushed %d plot(s) across %d grid(s)", len(flushed_plot_ids), len(flushed_grid_keys)) return {"flushed": len(flushed_plot_ids), "grids": flushed_grid_keys}