Spaces:
Paused
Paused
Download app.py from vincewin/CREST_gpu_worker: direct link, hf CLI and curl.
- Browser
- Download file 39.5 kB
-
https://huggingface.co/spaces/vincewin/CREST_gpu_worker/resolve/main/app.py
- Command line
-
hf download hf://spaces/vincewin/CREST_gpu_worker/app.py
-
curl -L -o app.py https://huggingface.co/spaces/vincewin/CREST_gpu_worker/resolve/main/app.py
39.5 kB
| """CREST_gpu_worker β ZeroGPU event worker (V27 P1.5 + P1.6 sessions). Since | |
| the HPC worker was released (2026-10-16) it is the only 2-D engine: it claims | |
| events/queue/ jobs, solves them in <=120 s GPU slices within a daily GPU | |
| budget (authenticated calls draw on the account's ZeroGPU quota), and β when | |
| PUBLISH=1 β publishes exactly like the HPC worker did. | |
| P1.6: solver state is a resident crestimap.session.EventSession per episode | |
| (depth AND momentum held in RAM between bundles), so returning to an episode | |
| this Space already solved is a minutes-long catch-up visit from the last | |
| obs-supported junction, not an hours-long anchored re-solve. Sessions for | |
| episodes this worker has visited get scheduling priority over new events. | |
| Queue protocol: identical to crestimap/worker.py (claim/heartbeat/stale | |
| takeover/failed.json). 30 m (dem_res "1") jobs only; never coarsens β an | |
| oversized or 10 m job is simply not claimed. | |
| ZeroGPU note: @spaces.GPU functions run in a forked child with the GPU; | |
| arguments/returns are pickled, and parent-side mutations inside the child are | |
| lost. gpu_chunk() therefore RETURNS session.state_tuple() and the parent | |
| reapplies it. Files the child writes (depth frames) land on the shared fs. | |
| Never prints or logs the HF token. | |
| """ | |
| from __future__ import annotations | |
| import datetime | |
| import io | |
| import json | |
| import os | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tarfile | |
| import tempfile | |
| import threading | |
| import time | |
| import traceback | |
| # ---------------------------------------------------------------- bootstrap | |
| APP = os.path.dirname(os.path.abspath(__file__)) | |
| FORK = "https://github.com/mchen15ouedu/CREST-iMAP.git" | |
| CREST_AI = "https://huggingface.co/spaces/vincewin/CREST_demo" | |
| # Solver commit, pinned like the CPU Spaces' Dockerfile CRESTIMAP_REF and the | |
| # Jetstream2 kit's FORK_REF: a push to v2 must never reach this worker on a | |
| # restart without review. c5184759 = draining-time outflow limiter + | |
| # interflow store, approved 2026-10-20. Full SHA only (a branch would float). | |
| FORK_REF = os.environ.get("CRESTIMAP_REF", | |
| "c518475956c933a9c0fc46962b3825685a54b181") | |
| def _git(*args, cwd=None): | |
| return subprocess.run(["git", *args], cwd=cwd, check=True, | |
| capture_output=True, text=True).stdout.strip() | |
| def _bootstrap(): | |
| """Blobless sparse clones: crestimap/ from the fork at FORK_REF, hf_data/ | |
| from the CREST_demo Space (eventsim/eventstore reuse β nothing | |
| reimplemented). | |
| crestimap is imported straight from the clone (a detached sparse checkout | |
| of FORK_REF), so provenance.stamp() records the pinned SHA on every | |
| manifest; a copied-out package has no .git and stamped only | |
| "2.0.0.dev0".""" | |
| fork = os.path.join(APP, "_fork") | |
| if not os.path.isdir(os.path.join(fork, "crestimap")): | |
| shutil.rmtree(fork, ignore_errors=True) | |
| # all branches are fetched (blobless), so a pinned commit stays | |
| # reachable whichever branch carries it | |
| _git("clone", "--filter=blob:none", "--no-checkout", FORK, fork) | |
| _git("sparse-checkout", "init", "--cone", cwd=fork) | |
| _git("sparse-checkout", "set", "crestimap", cwd=fork) | |
| _git("checkout", "--detach", FORK_REF, cwd=fork) | |
| want = _git("rev-parse", "--verify", f"{FORK_REF}^{{commit}}", cwd=fork) | |
| if _git("rev-parse", "HEAD", cwd=fork) != want: | |
| raise RuntimeError(f"crestimap clone is not at the pinned {FORK_REF}") | |
| if fork not in sys.path: | |
| sys.path.insert(0, fork) | |
| ai = os.path.join(APP, "_crest_ai") | |
| if not os.path.isdir(os.path.join(ai, "hf_data")): | |
| subprocess.run(["git", "clone", "--filter=blob:none", "--no-checkout", | |
| CREST_AI, ai], check=True) | |
| subprocess.run(["git", "checkout", "HEAD", "--", "hf_data"], | |
| cwd=ai, check=True) | |
| if ai not in sys.path: | |
| sys.path.insert(0, ai) | |
| _bootstrap() | |
| try: | |
| _FORK_SHA = subprocess.run( | |
| ["git", "-C", os.path.join(APP, "_fork"), "rev-parse", "HEAD"], | |
| capture_output=True, text=True, timeout=15).stdout.strip()[:12] or None | |
| except Exception: | |
| _FORK_SHA = None | |
| import gradio as gr # noqa: E402 | |
| try: | |
| import spaces # noqa: E402 | |
| HAS_ZERO = True | |
| except Exception: # local dev | |
| HAS_ZERO = False | |
| TS = "%Y-%m-%dT%H:%M:%SZ" | |
| TMIN = "%Y-%m-%dT%H:%MZ" | |
| QPREFIX = "events/queue" | |
| REPO = os.environ.get("CREST_FEEDBACK_REPO", "vincewin/CREST_data") | |
| # The HPC worker was released 2026-10-16; this Space is now the only 2-D | |
| # engine, so there is no reason to leave a job unclaimed for anyone else. | |
| ZERO_DELAY_S = float(os.environ.get("ZERO_DELAY_S", "0")) | |
| # GPU calls must be AUTHENTICATED to get the account's ZeroGPU quota (PRO: | |
| # 40 min/day, fixed 24 h window from first use). Self-calls over 127.0.0.1 | |
| # carry no X-IP-Token and land in a shared anonymous "Space app" pool that | |
| # gave ~0 GPU-s/day from 2026-09-23 to 10-16 ("Space app has reached its GPU | |
| # limit" on every claim). ZERO_AUTH=1 self-drives through the public Space | |
| # URL with HF_TOKEN instead. | |
| ZERO_AUTH = os.environ.get("ZERO_AUTH", "1") == "1" | |
| # Our own daily cap, below the 40-min PRO quota: the quota is shared with | |
| # every other ZeroGPU use under the same account, and past it ZeroGPU bills | |
| # prepaid credits automatically ($1 per 10 min). 0 disables the cap. | |
| DAILY_BUDGET_S = float(os.environ.get("ZERO_DAILY_BUDGET_S", "1800")) | |
| GPU_CALL_S = 120.0 # declared @spaces.GPU duration (admission needs it) | |
| if 0 < DAILY_BUDGET_S < GPU_CALL_S: | |
| DAILY_BUDGET_S = GPU_CALL_S # a smaller cap could never run | |
| # a NEW event (full anchored solve) is only claimed with this much budget | |
| # left; otherwise it would be claimed, paused ~a day, and solved stale | |
| MIN_NEW_S = float(os.environ.get("ZERO_MIN_NEW_S", "600")) | |
| # "No GPU was available after 60s" is platform capacity, not this job's | |
| # fault and not quota: wait and retry the same chunk. | |
| CAPACITY_BACKOFF_S = float(os.environ.get("ZERO_CAPACITY_BACKOFF_S", "120")) | |
| # Backup -> active drain under pressure (user directive 2026-08-14): with | |
| # more than this many jobs pending, skip the polite deferral and claim the | |
| # next unclaimed event immediately β one solver per event, so this Space | |
| # always works a DIFFERENT event than the HPC. | |
| PRESSURE_N = int(os.environ.get("ZERO_PRESSURE_N", "4")) | |
| STALE_S = float(os.environ.get("ZERO_STALE_S", "600")) | |
| HB_S = float(os.environ.get("ZERO_HB_S", "240")) | |
| POLL_S = float(os.environ.get("ZERO_POLL_S", "60")) | |
| # ZeroGPU quota exhaustion is NOT a poison job: release the claim (the HPC | |
| # or a later quota window takes the bundle) and stop claiming for a while. | |
| # Without this (seen 2026-09-11) the backup rung claimed a bundle, hit | |
| # "Space app has reached its GPU limit", and CONSUMED it as failed. | |
| QUOTA_BACKOFF_S = float(os.environ.get("ZERO_QUOTA_BACKOFF_S", "3600")) | |
| MAX_CELLS = int(os.environ.get("ZERO_MAX_CELLS", "2000000")) | |
| PUBLISH = os.environ.get("PUBLISH", "0") == "1" | |
| CHUNK0_SIM_S = float(os.environ.get("ZERO_CHUNK0_SIM_S", "300")) | |
| GPU_TARGET_S = float(os.environ.get("ZERO_GPU_TARGET_S", "80")) | |
| DEM_RES_DEG = {"1": 1.0 / 3600.0, "13": 1.0 / 10800.0} | |
| IDENT = f"zerogpu:{os.environ.get('SPACE_ID', 'local')}:{os.getpid()}" | |
| os.environ.setdefault("EVENT_ENGINE_IDENT", IDENT) # provenance stamp | |
| _tok = os.environ.get("HF_TOKEN", "").strip() | |
| if _tok: | |
| os.environ["HF_TOKEN"] = _tok # eventstore reads env | |
| MAX_SESSIONS = int(os.environ.get("ZERO_MAX_SESSIONS", "6")) | |
| # > one daily budget pause (~23.5 h), so parked sessions survive the pause and | |
| # the next bundle is a cheap catch-up rather than a full re-solve | |
| EVICT_IDLE_H = float(os.environ.get("ZERO_EVICT_IDLE_H", "36")) | |
| # cadence of the server-side tick loop (see _self_drive); matches the old | |
| # gr.Timer(5) so idle polling behaviour is unchanged | |
| SELF_TICK_S = float(os.environ.get("ZERO_SELF_TICK_S", "5")) | |
| LOG: list[str] = [] | |
| W = {"phase": "idle", "job": None, "runner": None, "chunk_sim_s": CHUNK0_SIM_S, | |
| "last_poll": 0.0, "last_hb": 0.0, "queued": "", "workdir": None, | |
| "done": set(), "solved": [], "t_wall0": 0.0, "last_tick": 0.0, | |
| # daily GPU budget: [(start, seconds), ...] of GPU calls in a SLIDING | |
| # 24 h window, so use stays under the cap in any 24 h (hence in any of | |
| # ZeroGPU's own windows); RAM-only β keep the account's prepaid credit | |
| # balance at $0 so an over-run after a restart becomes a quota error | |
| "gpu_calls": [], "capacity_until": 0.0, "quota_until": 0.0, | |
| "paused_note": "", "auth_suspect": False, | |
| # /tick calls by outcome (see tick_api); "accepted" must keep rising | |
| "tick_api": {"accepted": 0, "rejected": 0, "empty": 0, | |
| "last_reject_log": 0.0}, | |
| # what the last _scan saw (status "scan"): time, queue ids, backlog, | |
| # candidates and per-reason skip counts | |
| "scan": {}, | |
| # P1.6 resident sessions: ev -> {"session", "bbox", "visits", "last", | |
| # "root"}. RAM-only; a Space rebuild recovers via the bundle's | |
| # init_depth (state-hierarchy level 2). | |
| "sessions": {}} | |
| _tick_lock = threading.Lock() | |
| # secret the self-drive loop passes to /tick (in-process only; never logged) | |
| _SELF_KEY = __import__("secrets").token_hex(16) | |
| def log(msg: str): | |
| LOG.append(f"[{datetime.datetime.utcnow():%H:%M:%S}Z] {msg}") | |
| del LOG[:-300] | |
| print(LOG[-1], flush=True) | |
| def _api(): | |
| from huggingface_hub import HfApi | |
| return HfApi(token=_tok or None) | |
| def _read_json(path: str): | |
| from huggingface_hub import hf_hub_download | |
| try: | |
| p = hf_hub_download(REPO, path, repo_type="dataset", token=_tok or None, | |
| force_download=True) | |
| with open(p, encoding="utf-8") as fp: | |
| return json.load(fp) | |
| except Exception: | |
| return None | |
| def _commit(ops, message: str) -> bool: | |
| try: | |
| _api().create_commit(repo_id=REPO, repo_type="dataset", operations=ops, | |
| commit_message=message) | |
| return True | |
| except Exception as e: | |
| log(f"commit failed ({message}): {type(e).__name__}") | |
| return False | |
| def _commit_claim(ev: str, queued: str) -> bool: | |
| from huggingface_hub import CommitOperationAdd | |
| body = json.dumps({"worker": IDENT, | |
| "hb": datetime.datetime.utcnow().strftime(TS), | |
| "queued": queued}) | |
| return _commit([CommitOperationAdd(f"{QPREFIX}/{ev}.claim", | |
| io.BytesIO(body.encode()))], | |
| f"claim {ev} [{IDENT}]") | |
| def _used_s(now: float) -> float: | |
| """GPU seconds booked in the last 24 h (old calls are forgotten).""" | |
| W["gpu_calls"] = [(t, s) for t, s in W["gpu_calls"] if now - t < 86400.0] | |
| return sum(s for _, s in W["gpu_calls"]) | |
| def _budget_left(now: float) -> float: | |
| """Seconds of daily GPU budget left (inf when uncapped).""" | |
| if DAILY_BUDGET_S <= 0: | |
| return float("inf") | |
| return DAILY_BUDGET_S - _used_s(now) | |
| def _budget_free_at(now: float, need: float) -> float: | |
| """When at least `need` seconds of budget will be free again.""" | |
| left = _budget_left(now) | |
| for t, s in sorted(W["gpu_calls"]): | |
| if left >= need: | |
| break | |
| left += s | |
| now = t + 86400.0 | |
| return now | |
| def _charge(seconds: float, started: float): | |
| """Book a GPU call that began at `started` and lasted `seconds` (wall, | |
| incl. fork/pickle overhead β conservative).""" | |
| if DAILY_BUDGET_S > 0: | |
| W["gpu_calls"].append((started, max(0.0, seconds))) | |
| def _gpu_wait_reason(now: float) -> str | None: | |
| """Why no GPU call may start now (None = go).""" | |
| if now < W["quota_until"]: | |
| return f"ZeroGPU quota exhausted until {_hhmm(W['quota_until'])}" | |
| if now < W["capacity_until"]: | |
| return "no ZeroGPU capacity β retrying shortly" | |
| if _budget_left(now) < GPU_CALL_S: | |
| return (f"daily GPU budget used ({_used_s(now) / 60:.0f} of " | |
| f"{DAILY_BUDGET_S / 60:.0f} min in 24 h) β resumes " | |
| f"{_hhmm(_budget_free_at(now, GPU_CALL_S))}") | |
| return None | |
| def _hhmm(t: float) -> str: | |
| return datetime.datetime.utcfromtimestamp(t).strftime("%m-%d %H:%MZ") | |
| def _quota_retry_s(exc) -> float | None: | |
| """Seconds until ZeroGPU says to retry ('... try again in H:MM:SS'), or | |
| None when the error names no retry time (e.g. the anonymous pool's | |
| 'Space app has reached its GPU limit').""" | |
| import re | |
| m = re.search(r"try again in (\d+):(\d{2}):(\d{2})", str(exc), re.I) | |
| if m: | |
| h, mi, s = (int(x) for x in m.groups()) | |
| return float(h * 3600 + mi * 60 + s) + 60.0 | |
| return None | |
| def _auth_suspect(exc) -> bool: | |
| """The error text of the ANONYMOUS pool: our calls were not attributed | |
| to the account (ZERO_AUTH did not take effect).""" | |
| m = f"{exc}".lower() | |
| return "space app has reached its gpu limit" in m or "unlogged" in m | |
| def _is_capacity_error(exc) -> bool: | |
| return "no gpu was available" in f"{exc}".lower() | |
| def _claim_fresh(claim) -> bool: | |
| if not claim: | |
| return False | |
| try: | |
| hb = datetime.datetime.strptime(claim.get("hb", ""), TS) | |
| except ValueError: | |
| return False | |
| return (datetime.datetime.utcnow() - hb).total_seconds() < STALE_S | |
| def _scan(): | |
| """Oldest BACKUP-eligible job: bundle present, 30 m, inside cell budget, | |
| unclaimed (or stale), and queued more than ZERO_DELAY_S ago (the HPC gets | |
| first shot β this Space is ladder rung 2, not a race participant).""" | |
| try: | |
| files = _api().list_repo_files(REPO, repo_type="dataset") | |
| except Exception as e: | |
| log(f"list_repo_files failed: {type(e).__name__}") | |
| return None | |
| qf = [f for f in files if f.startswith(QPREFIX + "/")] | |
| ids = sorted({os.path.basename(f)[:-5] for f in qf | |
| if f.endswith(".json") and not f.endswith(".failed.json")}) | |
| now = datetime.datetime.utcnow() | |
| # queue pressure: backlog > PRESSURE_N promotes this Space from deferred | |
| # backup to active drain (claim immediately; claims keep events disjoint) | |
| backlog = sum(1 for ev in ids if f"{QPREFIX}/{ev}.forcing.tar.gz" in qf) | |
| delay = 0.0 if backlog > PRESSURE_N else ZERO_DELAY_S | |
| if backlog > PRESSURE_N and W.get("pressure") != backlog: | |
| W["pressure"] = backlog | |
| log(f"queue pressure: {backlog} pending jobs > {PRESSURE_N} β " | |
| f"claiming without deferral") | |
| cands = [] | |
| skip = W["scan"]["skipped"] = {} | |
| W["scan"].update(at=now.strftime(TS), ids=len(ids), backlog=backlog) | |
| def _skip(why): | |
| skip[why] = skip.get(why, 0) + 1 | |
| for ev in ids: | |
| if f"{QPREFIX}/{ev}.forcing.tar.gz" not in qf: | |
| _skip("no_forcing") | |
| continue | |
| spec = _read_json(f"{QPREFIX}/{ev}.json") | |
| if not spec: | |
| _skip("spec_unreadable") | |
| continue | |
| # This backup image implements only the original forcing contract. | |
| # New coupling jobs must remain available to a compatible HPC worker. | |
| try: | |
| protocol = int(spec.get("worker_protocol", spec.get("coupling_protocol", 1))) | |
| except (TypeError, ValueError): | |
| _skip("unsupported_protocol") | |
| continue | |
| if protocol > 1: | |
| _skip("unsupported_protocol") | |
| continue | |
| required_engine = spec.get("required_engine_prefix") | |
| if required_engine and not IDENT.startswith(required_engine): | |
| _skip("engine_mismatch") | |
| continue | |
| if (ev, spec.get("queued", "")) in W["done"]: | |
| _skip("done") | |
| continue | |
| if str(spec.get("dem_res", "1")) != "1": | |
| _skip("not_30m") | |
| continue # 30 m only on this rung | |
| try: | |
| age = (now - datetime.datetime.strptime(spec["queued"], TS) | |
| ).total_seconds() | |
| except Exception: | |
| age = 0.0 | |
| if age < delay: | |
| _skip("deferred") | |
| continue | |
| if not spec.get("domain"): | |
| # pre-basin bundle (rectangular-window era): never solved as a | |
| # rectangle β the runner re-enqueues it with a HUC12 basin domain | |
| log(f"skip {ev}: no basin domain in spec β awaiting re-enqueue") | |
| _skip("no_domain") | |
| continue | |
| w, s, e, n = spec["bbox_basin"] | |
| res = DEM_RES_DEG["1"] | |
| cells = int((spec.get("domain") or {}).get("n_bbox") or | |
| int(round((n - s) / res)) * int(round((e - w) / res))) | |
| if cells > MAX_CELLS: | |
| log(f"skip {ev}: {cells / 1e6:.1f} M cells > " | |
| f"{MAX_CELLS / 1e6:.1f} M budget (never coarsen)") | |
| _skip("over_budget") | |
| continue | |
| if f"{QPREFIX}/{ev}.claim" in qf: | |
| claim = _read_json(f"{QPREFIX}/{ev}.claim") | |
| if _claim_fresh(claim) and claim.get("worker") != IDENT: | |
| _skip("claimed") | |
| continue | |
| cands.append({"id": ev, "spec": spec}) | |
| W["scan"]["candidates"] = len(cands) | |
| # P1.6: episodes with a resident session first β a catch-up visit is | |
| # minutes; new events (full anchored solve) fill the remaining time | |
| cands.sort(key=lambda j: (j["id"] not in W["sessions"], | |
| j["spec"].get("queued", ""))) | |
| return cands[0] if cands else None | |
| # ------------------------------------------------------------------ GPU work | |
| def _gpu_chunk_impl(session, sim_s: float, device: str): | |
| done = session.chunk(sim_s, device=device) | |
| return done, session.state_tuple() | |
| if HAS_ZERO: | |
| def gpu_chunk(session, sim_s: float): | |
| return _gpu_chunk_impl(session, sim_s, "cuda") | |
| else: | |
| def gpu_chunk(session, sim_s: float): | |
| return _gpu_chunk_impl(session, sim_s, "cpu") | |
| # ------------------------------------------------------------------ lifecycle | |
| def _start_job(job): | |
| from crestimap import EventConfig | |
| from crestimap.session import EventSession | |
| ev, spec = job["id"], job["spec"] | |
| # CLAIM-LESS SHADOW: an unvalidated backup must never block the ladder. | |
| # In "on" mode the Space (and the HPC's scan) treat any fresh claim as | |
| # "a worker is on it" β a shadow claim would make the Space wait 45 min | |
| # for a result we are forbidden to publish, and stop the HPC claiming. | |
| # So we only write claims when we're allowed to deliver (PUBLISH=1). | |
| if PUBLISH: | |
| if not _commit_claim(ev, spec.get("queued", "")): | |
| return False | |
| time.sleep(3) | |
| claim = _read_json(f"{QPREFIX}/{ev}.claim") | |
| if not claim or claim.get("worker") != IDENT: | |
| log(f"lost claim race on {ev}") | |
| return False | |
| rec = W["sessions"].get(ev) | |
| if rec and (rec["bbox"] != spec["bbox_basin"] | |
| or rec.get("cold") != spec.get("cold")): | |
| log(f"{ev}: {'cold re-run requested' if rec.get('cold') != spec.get('cold') else 'bbox changed'}" | |
| f" β dropping resident session") | |
| shutil.rmtree(rec["root"], ignore_errors=True) | |
| W["sessions"].pop(ev, None) | |
| rec = None | |
| if rec is None: | |
| rec = {"session": None, "root": tempfile.mkdtemp(prefix=f"sess_{ev}_"), | |
| "bbox": spec["bbox_basin"], "cold": spec.get("cold"), | |
| "visits": 0, "last": time.time()} | |
| W["sessions"][ev] = rec # registered now so _fail_job cleans it up | |
| n = rec["visits"] + 1 | |
| forcing = os.path.join(rec["root"], f"forcing_{n}") | |
| out_dir = os.path.join(rec["root"], f"out_{n}") | |
| os.makedirs(forcing, exist_ok=True) | |
| from huggingface_hub import hf_hub_download | |
| tar = hf_hub_download(REPO, f"{QPREFIX}/{ev}.forcing.tar.gz", | |
| repo_type="dataset", token=_tok or None, | |
| force_download=True) | |
| with tarfile.open(tar) as tf: | |
| tf.extractall(forcing) | |
| log(f"{ev}: forcing extracted ({len(os.listdir(forcing))} grids)") | |
| t0 = datetime.datetime.strptime(spec["t0"], TMIN) | |
| t_end = datetime.datetime.strptime(spec["t_end"], TMIN) | |
| if rec["session"] is None: | |
| cfg = EventConfig( | |
| event_id=ev, bbox=tuple(spec["bbox_basin"]), | |
| t0=t0, t_end=t_end, | |
| sim_start=datetime.datetime.strptime(spec["sim_start"], TMIN), | |
| ef5_output_dir=forcing, out_dir=out_dir, | |
| model=spec.get("model", "crest"), dem_res="1", | |
| dem_cache=os.path.join(tempfile.gettempdir(), "dem_cache"), | |
| max_cells=MAX_CELLS, trigger=spec.get("trigger"), | |
| device="cpu", progress=log) # module-level fn: fork-picklable | |
| session = EventSession(cfg, resume=True) # CPU: DEM + pre-wet/resume | |
| if max(session.grid.dx, session.grid.dy) > 45.0: # ~30 m native | |
| raise RuntimeError(f"grid dx {session.grid.dx:.0f} m β load_dem " | |
| f"coarsened; refusing (resolution-first)") | |
| rec["session"] = session | |
| session = rec["session"] | |
| session.begin_visit(forcing, out_dir, t0, t_end) | |
| ny, nx = session.grid.z.shape | |
| W.update(job=job, runner=session, workdir=None, phase="solving", | |
| queued=spec.get("queued", ""), chunk_sim_s=CHUNK0_SIM_S, | |
| t_wall0=time.time(), last_hb=time.time()) | |
| log(f"{ev}: visit {n} begins ({ny}x{nx}, " | |
| f"{(session.visit['t_end_rel'] - session.t) / 3600:.1f} sim-h from " | |
| f"{session.state_time():%m-%d %H:%M})") | |
| return True | |
| def _finish_job(): | |
| job, session = W["job"], W["runner"] | |
| ev, spec = job["id"], job["spec"] | |
| queued = W["queued"] | |
| rec = W["sessions"][ev] | |
| out_dir = session.visit["out_dir"] | |
| n = rec["visits"] + 1 | |
| from huggingface_hub import CommitOperationDelete | |
| try: | |
| manifest = session.end_visit() # rolls back to the junction | |
| manifest["gauge"] = spec["gauge"]["id"] | |
| manifest["hydro"] = spec.get("hydro") or [] | |
| manifest["status"] = "active" | |
| manifest["basin"] = spec.get("basin") | |
| manifest["huc12s"] = spec.get("huc12s") | |
| manifest["domain"] = {**{k: v for k, v in (spec.get("domain") or {}).items() | |
| if k != "hucs"}, | |
| **(manifest.get("domain") or {})} | |
| from hf_data import eventsim, eventstore | |
| eventsim._update_archive(out_dir, manifest, log) | |
| if not manifest.get("archive_frames"): | |
| manifest["status"] = "ended" | |
| manifest["dry"] = True | |
| eventsim._make_pngs(out_dir, manifest) | |
| wall = time.time() - W["t_wall0"] | |
| log(f"{ev}: visit {n} done β {len(manifest['frames'])} frames, " | |
| f"wall {wall / 60:.1f} min") | |
| if PUBLISH: | |
| ok = eventstore.publish_event(out_dir, manifest) | |
| log(f"{ev}: publish {'OK' if ok else 'FAILED'}") | |
| ops = [CommitOperationDelete(f"{QPREFIX}/{ev}.claim")] | |
| cur = _read_json(f"{QPREFIX}/{ev}.json") | |
| if ok and cur and cur.get("queued", "") == queued: | |
| ops += [CommitOperationDelete(f"{QPREFIX}/{ev}.json"), | |
| CommitOperationDelete(f"{QPREFIX}/{ev}.forcing.tar.gz")] | |
| _commit(ops, f"zero-worker done {ev}") | |
| else: | |
| log(f"{ev}: shadow done β no claim was held, nothing to release") | |
| W["solved"].append({"id": ev, "visit": n, | |
| "frames": len(manifest["frames"]), | |
| "wall_min": round(wall / 60, 1), | |
| "published": PUBLISH}) | |
| rec["visits"] = n | |
| rec["last"] = time.time() | |
| # bound disk: only the just-published visit's dirs remain | |
| for stale in os.listdir(rec["root"]): | |
| if stale not in (f"forcing_{n}", f"out_{n}"): | |
| shutil.rmtree(os.path.join(rec["root"], stale), | |
| ignore_errors=True) | |
| finally: | |
| W["done"].add((ev, queued)) | |
| W.update(job=None, runner=None, workdir=None, phase="idle") | |
| def _is_quota_error(exc) -> bool: | |
| m = f"{type(exc).__name__}: {exc}".lower() | |
| return "gpu limit" in m or "quota" in m | |
| def _fail_job(exc): | |
| job = W["job"] | |
| ev, queued = job["id"], W["queued"] | |
| tb = "\n".join(traceback.format_exc().strip().splitlines()[-8:]) | |
| if _is_quota_error(exc): | |
| # not this job's fault: hand it back untouched and back off | |
| W["quota_until"] = time.time() + QUOTA_BACKOFF_S | |
| log(f"{ev}: ZeroGPU quota exhausted β releasing the claim, no " | |
| f"claims for {QUOTA_BACKOFF_S / 60:.0f} min ({exc})") | |
| if PUBLISH: | |
| from huggingface_hub import CommitOperationDelete | |
| _commit([CommitOperationDelete(f"{QPREFIX}/{ev}.claim")], | |
| f"zero-worker quota β released {ev}") | |
| rec = W["sessions"].pop(ev, None) # mid-visit state is untrustworthy | |
| if rec: | |
| shutil.rmtree(rec["root"], ignore_errors=True) | |
| W.update(job=None, runner=None, workdir=None, phase="idle") | |
| return # NOT added to done: retryable | |
| log(f"{ev}: FAILED β {type(exc).__name__}: {exc}") | |
| if PUBLISH: # shadow held no claim | |
| from huggingface_hub import CommitOperationAdd, CommitOperationDelete | |
| ops = [CommitOperationDelete(f"{QPREFIX}/{ev}.claim")] | |
| cur = _read_json(f"{QPREFIX}/{ev}.json") | |
| if cur and cur.get("queued", "") == queued: | |
| body = json.dumps({"worker": IDENT, | |
| "failed": datetime.datetime.utcnow().strftime(TS), | |
| "error": f"{type(exc).__name__}: {exc}"[:2000], | |
| "traceback_tail": tb[:4000]}) | |
| ops += [CommitOperationAdd(f"{QPREFIX}/{ev}.failed.json", | |
| io.BytesIO(body.encode())), | |
| CommitOperationDelete(f"{QPREFIX}/{ev}.json"), | |
| CommitOperationDelete(f"{QPREFIX}/{ev}.forcing.tar.gz")] | |
| _commit(ops, f"zero-worker failed {ev}") | |
| W["done"].add((ev, queued)) | |
| # mid-visit state is not trustworthy β drop the session; the next | |
| # bundle rebuilds via init_depth (state-hierarchy level 2) | |
| rec = W["sessions"].pop(ev, None) | |
| if rec: | |
| shutil.rmtree(rec["root"], ignore_errors=True) | |
| W.update(job=None, runner=None, workdir=None, phase="idle") | |
| def _evict_sessions(): | |
| """Idle and LRU eviction of parked sessions (never the active one).""" | |
| now = time.time() | |
| active = (W["job"] or {}).get("id") | |
| for ev in list(W["sessions"]): | |
| if ev == active: | |
| continue | |
| idle_h = (now - W["sessions"][ev]["last"]) / 3600.0 | |
| if idle_h > EVICT_IDLE_H: | |
| log(f"evicting session {ev} (idle {idle_h:.0f} h)") | |
| shutil.rmtree(W["sessions"][ev]["root"], ignore_errors=True) | |
| del W["sessions"][ev] | |
| while len(W["sessions"]) > MAX_SESSIONS: | |
| parked = [e for e in W["sessions"] if e != active] | |
| if not parked: | |
| break | |
| ev = min(parked, key=lambda e: W["sessions"][e]["last"]) | |
| log(f"evicting session {ev} (LRU, cap {MAX_SESSIONS})") | |
| shutil.rmtree(W["sessions"][ev]["root"], ignore_errors=True) | |
| del W["sessions"][ev] | |
| def view(): | |
| """What a browser sees: status + log only. It never advances the state | |
| machine β a visitor's request would run @spaces.GPU under the VISITOR's | |
| ZeroGPU identity, and an anonymous visitor's quota error could stall the | |
| only 2-D engine for a day.""" | |
| return _status(), "\n".join(LOG[-40:]) | |
| def tick_api(key: str = ""): | |
| """The /tick endpoint: advances the worker only for the self-drive loop, | |
| which passes this process's secret; anyone else just gets the status. | |
| Every call is counted (status "tick_api"): from 10-19 to 10-20 the worker | |
| sat idle next to an eligible job with no log line at all, which means | |
| either the key never arrived or tick() found nothing. These counters tell | |
| the two apart from outside.""" | |
| c = W["tick_api"] | |
| if not key: | |
| c["empty"] += 1 | |
| return view() | |
| if key != _SELF_KEY: | |
| c["rejected"] += 1 | |
| if time.time() - c["last_reject_log"] > 3600: | |
| c["last_reject_log"] = time.time() | |
| log(f"tick_api: rejected a key (len {len(key)}; " | |
| f"{c['rejected']} so far)") | |
| return view() | |
| c["accepted"] += 1 | |
| if c["accepted"] == 1: | |
| log("tick_api: self-drive key accepted β worker advancing") | |
| return tick() | |
| def tick(): | |
| """One state-machine step (single-flight); called via tick_api by the | |
| self-drive loop.""" | |
| if not _tick_lock.acquire(blocking=False): | |
| return _status(), "\n".join(LOG[-40:]) | |
| try: | |
| now = time.time() | |
| wait = _gpu_wait_reason(now) | |
| if (wait or "") != W["paused_note"]: | |
| W["paused_note"] = wait or "" | |
| log(f"paused: {wait}" if wait else "GPU available β resuming") | |
| if W["phase"] == "idle": | |
| # never claim a job we couldn't start computing on right away | |
| if now - W["last_poll"] >= POLL_S and wait is None: | |
| W["last_poll"] = now | |
| _evict_sessions() | |
| job = _scan() | |
| if (job and job["id"] not in W["sessions"] | |
| and _budget_left(now) < MIN_NEW_S): | |
| job = None # a new event waits for a fuller budget | |
| if job: | |
| kind = ("catch-up" if job["id"] in W["sessions"] | |
| else "new") | |
| log(f"claiming {job['id']} (backup rung, {kind}, " | |
| f"queued {job['spec'].get('queued')})") | |
| try: | |
| _start_job(job) | |
| except Exception as e: | |
| W["job"] = job | |
| W["queued"] = job["spec"].get("queued", "") | |
| _fail_job(e) | |
| elif W["phase"] == "solving": | |
| ev = W["job"]["id"] | |
| if PUBLISH and now - W["last_hb"] >= HB_S: | |
| _commit_claim(ev, W["queued"]) | |
| W["last_hb"] = now | |
| session = W["runner"] | |
| if wait is not None: | |
| # paused mid-visit: the session keeps its state in RAM and | |
| # the claim stays fresh (heartbeat above); resume later | |
| return _status(), "\n".join(LOG[-40:]) | |
| t0 = time.time() | |
| try: | |
| done, state = gpu_chunk(session, W["chunk_sim_s"]) | |
| session.set_state(state) | |
| except Exception as e: | |
| # admission failures leave the session untouched (the chunk | |
| # never ran): hold the job and retry the same chunk later β | |
| # but only when ZeroGPU names a retry time; a quota error | |
| # without one (the anonymous pool: our identity was not | |
| # recognised) takes the old release-and-back-off path | |
| retry = _quota_retry_s(e) if _is_quota_error(e) else None | |
| if _is_capacity_error(e): | |
| W["capacity_until"] = time.time() + CAPACITY_BACKOFF_S | |
| log(f"{ev}: no ZeroGPU capacity β retry in " | |
| f"{CAPACITY_BACKOFF_S:.0f} s ({e})") | |
| elif retry is not None: | |
| W["quota_until"] = time.time() + retry | |
| log(f"{ev}: ZeroGPU quota exhausted β holding the job " | |
| f"until {_hhmm(W['quota_until'])} ({e})") | |
| else: | |
| if _auth_suspect(e): | |
| W["auth_suspect"] = True | |
| log(f"{ev}: anonymous-pool quota error β GPU calls are " | |
| f"NOT attributed to the account (ZERO_AUTH failed?)") | |
| elif not _is_quota_error(e): | |
| _charge(time.time() - t0, t0) # it ran, then failed | |
| _fail_job(e) | |
| return _status(), "\n".join(LOG[-40:]) | |
| _charge(time.time() - t0, t0) | |
| wall = max(time.time() - t0, 1e-3) | |
| rate = W["chunk_sim_s"] / wall # sim-s per wall-s | |
| W["chunk_sim_s"] = float(min(max(GPU_TARGET_S * rate, 60.0), 14400.0)) | |
| total_h = session.visit["t_end_rel"] / 3600.0 | |
| log(f"{ev}: sim t={session.t / 3600:.2f}/{total_h:.0f} h " | |
| f"(chunk {wall:.0f} s wall, next {W['chunk_sim_s'] / 60:.0f} sim-min)") | |
| if done: | |
| try: | |
| _finish_job() | |
| except Exception as e: | |
| _fail_job(e) | |
| return _status(), "\n".join(LOG[-40:]) | |
| finally: | |
| _tick_lock.release() | |
| def _status(): | |
| s = W["runner"] | |
| return {"ident": IDENT, "phase": W["phase"], "publish": PUBLISH, | |
| "job": (W["job"] or {}).get("id"), | |
| "sim_h": round(s.t / 3600, 2) if s else None, | |
| "total_h": (round(s.visit["t_end_rel"] / 3600, 1) | |
| if s and s.visit else None), | |
| "chunk_sim_min": round(W["chunk_sim_s"] / 60, 1), | |
| # seconds since the server-side loop last completed a tick β the | |
| # liveness signal: if this grows without bound the worker is idle | |
| # even though the container reads RUNNING (the 2026-08-24 failure) | |
| "tick_age_s": (round(time.time() - W["last_tick"], 1) | |
| if W["last_tick"] else None), | |
| "sessions": {ev: {"visits": r["visits"], | |
| "held_at": (r["session"].state_time() | |
| .strftime("%m-%d %H:%MZ") | |
| if r["session"] else None)} | |
| for ev, r in W["sessions"].items()}, | |
| "solved": W["solved"][-5:], "zero_gpu": HAS_ZERO, | |
| "tick_api": {k: W["tick_api"][k] | |
| for k in ("accepted", "rejected", "empty")}, | |
| "scan": W["scan"], | |
| "fork_sha": _FORK_SHA, | |
| "gpu_budget": {"used_min_24h": round(_used_s(time.time()) / 60, 1), | |
| "budget_min": round(DAILY_BUDGET_S / 60, 1), | |
| "authenticated": bool(ZERO_AUTH and _tok), | |
| "auth_suspect": W["auth_suspect"], | |
| "paused": W["paused_note"] or None}} | |
| def _keep_awake(): | |
| import requests | |
| host = os.environ.get("SPACE_HOST", "") | |
| while host: | |
| time.sleep(900) | |
| try: | |
| requests.get(f"https://{host}", timeout=30) | |
| except Exception: | |
| pass | |
| def _self_drive(): | |
| """Server-side tick loop β the worker's actual engine. | |
| gr.Timer is a FRONTEND timer: it only fires while a browser session has | |
| the Space open, and demo.load only fires on a page load. _keep_awake's | |
| plain GET keeps the container off sleep but opens no Gradio session, so | |
| with nobody watching this worker sat RUNNING and completely idle β found | |
| 2026-08-24: 59 min of silence between boot and the first externally | |
| triggered /tick, and every past ZeroGPU solve coincided with someone | |
| polling it. The fleet/ungauged workers self-drive with a plain thread; | |
| this brings the GPU worker in line. | |
| The tick is driven through the Space's own /tick API over localhost | |
| rather than by calling tick() inline, so the state machine advances on | |
| exactly the request path it was built for (queue single-flight, and the | |
| @spaces.GPU allocation happens inside a normal Gradio request).""" | |
| from gradio_client import Client | |
| base = f"http://127.0.0.1:{os.environ.get('GRADIO_SERVER_PORT', '7860')}" | |
| space = os.environ.get("SPACE_ID", "") | |
| if ZERO_AUTH and _tok and space: | |
| # through HF's proxy with the account token: the proxy attaches the | |
| # caller identity (X-IP-Token), so @spaces.GPU draws on the account's | |
| # ZeroGPU quota instead of the anonymous per-Space pool | |
| src, kw, how = space, {"hf_token": _tok}, "authenticated (account quota)" | |
| else: | |
| src, kw, how = base, {}, "localhost, unauthenticated (anonymous pool)" | |
| client = None | |
| while True: | |
| try: | |
| if client is None: | |
| # token positionally: gradio_client 1.x names it hf_token, | |
| # 2.x names it token | |
| client = (Client(src, kw["hf_token"], verbose=False) if kw | |
| else Client(src, verbose=False)) | |
| log(f"self-drive: tick loop connected β {how}, " | |
| f"{SELF_TICK_S:.0f} s cadence") | |
| # bounded wait: a half-open proxy connection must not hang the | |
| # only engine forever (a healthy tick is <= ~3 min) | |
| client.submit(_SELF_KEY, api_name="/tick").result(timeout=900) | |
| W["last_tick"] = time.time() | |
| except Exception as e: # server still booting, etc. | |
| try: | |
| if client is not None: | |
| client.close() | |
| except Exception: | |
| pass | |
| client = None | |
| log(f"self-drive: {type(e).__name__}: {str(e)[:200]} β retrying") | |
| time.sleep(15) | |
| continue | |
| time.sleep(SELF_TICK_S) | |
| threading.Thread(target=_keep_awake, daemon=True).start() | |
| threading.Thread(target=_self_drive, daemon=True).start() | |
| log(f"boot: {IDENT} publish={PUBLISH} zero={HAS_ZERO} " | |
| f"delay={ZERO_DELAY_S:.0f}s budget={MAX_CELLS / 1e6:.1f}M cells " | |
| f"gpu/day={DAILY_BUDGET_S / 60:.0f} min auth={bool(ZERO_AUTH and _tok)} " | |
| f"crestimap={_FORK_SHA}") | |
| with gr.Blocks(title="CREST GPU worker (ZeroGPU)") as demo: | |
| gr.Markdown("## CREST event worker β ZeroGPU\n" | |
| "The 2-D inundation engine since the HPC worker was released " | |
| "(2026-10-16). Works within a daily GPU budget; shadow mode " | |
| "unless PUBLISH=1.") | |
| st = gr.JSON(label="worker state") | |
| lg = gr.Textbox(label="log", lines=18, max_lines=18) | |
| # the page only DISPLAYS (view); the worker advances through /tick, | |
| # which needs the in-process key the self-drive loop sends | |
| t = gr.Timer(5) | |
| t.tick(view, outputs=[st, lg], api_name=False) | |
| demo.load(view, outputs=[st, lg], api_name=False) | |
| key_in = gr.Textbox(visible=False) | |
| tick_btn = gr.Button(visible=False) | |
| tick_btn.click(tick_api, inputs=[key_in], outputs=[st, lg], | |
| api_name="tick", concurrency_limit=1) | |
| demo.queue().launch() | |