Spaces:
Running
Reliability: bake ESM-2 weights + bound in-memory jobs/rate-limit
Browse filesThree audit quick-wins:
- Dockerfile: HF_HOME moved to a BAKED /opt/hf-cache and the default ESM-2 (35M)
weights are prefetched at build time, so a cold start no longer re-downloads
them (HF_HOME used to live under ephemeral /tmp). Best-effort prefetch (|| echo)
so a build-time hiccup can't break the image — it just lazy-loads at runtime.
- server.py: cap _JOBS at 2000 and evict the oldest (with their edit locks) so a
long-lived worker can't grow unbounded.
- server.py: the rate-limit table now evicts the STALEST buckets when it gets
large, instead of clear()-ing the whole thing (which reset every honest user's
window and could be abused by a unique-IP flood).
Verified: 324 tests pass; _JOBS cap evicts oldest + prunes locks; rate-limit
drops stalest buckets (recent kept), no global wipe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Dockerfile +12 -3
- dee/server.py +21 -2
|
@@ -35,15 +35,24 @@ RUN pip install --no-cache-dir -e .
|
|
| 35 |
# non-root user a writable /tmp but not /app/.dee).
|
| 36 |
ENV HOME=/tmp \
|
| 37 |
XDG_CACHE_HOME=/tmp/.cache \
|
| 38 |
-
HF_HOME=/
|
|
|
|
| 39 |
HOST=0.0.0.0 \
|
| 40 |
PORT=7860 \
|
| 41 |
PYTHONUNBUFFERED=1
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
| 44 |
|
| 45 |
USER app
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
EXPOSE 7860
|
| 48 |
|
| 49 |
# Production WSGI server. ONE gthread worker (state lives in-process), threads
|
|
|
|
| 35 |
# non-root user a writable /tmp but not /app/.dee).
|
| 36 |
ENV HOME=/tmp \
|
| 37 |
XDG_CACHE_HOME=/tmp/.cache \
|
| 38 |
+
HF_HOME=/opt/hf-cache \
|
| 39 |
+
HF_HUB_DISABLE_TELEMETRY=1 \
|
| 40 |
HOST=0.0.0.0 \
|
| 41 |
PORT=7860 \
|
| 42 |
PYTHONUNBUFFERED=1
|
| 43 |
+
# HF_HOME now points at a BAKED, image-resident dir (not ephemeral /tmp) so the
|
| 44 |
+
# default model's weights persist across Space restarts instead of re-downloading.
|
| 45 |
+
RUN mkdir -p /tmp/.cache /tmp/.dee/output /tmp/.dee/state /opt/hf-cache \
|
| 46 |
+
&& chown -R app:app /tmp/.dee /tmp/.cache /opt/hf-cache
|
| 47 |
|
| 48 |
USER app
|
| 49 |
|
| 50 |
+
# Bake the default ESM-2 (35M) weights into the image so a cold start doesn't pay
|
| 51 |
+
# the re-download cost. Best-effort: if the prefetch fails at build time, the
|
| 52 |
+
# build still succeeds and the app simply lazy-loads the model at runtime.
|
| 53 |
+
RUN python -c "from transformers import AutoTokenizer, AutoModelForMaskedLM; m='facebook/esm2_t12_35M_UR50D'; AutoTokenizer.from_pretrained(m); AutoModelForMaskedLM.from_pretrained(m)" \
|
| 54 |
+
|| echo 'ESM-2 prefetch skipped — model will lazy-load at runtime'
|
| 55 |
+
|
| 56 |
EXPOSE 7860
|
| 57 |
|
| 58 |
# Production WSGI server. ONE gthread worker (state lives in-process), threads
|
|
@@ -268,9 +268,19 @@ def _get_job(job_id: str) -> Optional[JobState]:
|
|
| 268 |
return _JOBS.get(job_id)
|
| 269 |
|
| 270 |
|
|
|
|
|
|
|
|
|
|
| 271 |
def _put_job(job: JobState) -> None:
|
| 272 |
with _JOBS_LOCK:
|
| 273 |
_JOBS[job.job_id] = job
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
|
| 276 |
# ----------------------------------------------------------------- preview cache
|
|
@@ -812,9 +822,18 @@ def _check_rate_limit(path: str, key: str):
|
|
| 812 |
now = time.monotonic()
|
| 813 |
try:
|
| 814 |
with _RL_LOCK:
|
| 815 |
-
#
|
|
|
|
|
|
|
|
|
|
| 816 |
if len(_RL_HITS) > _RL_MAX_KEYS:
|
| 817 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 818 |
dq = _RL_HITS.get(bucket)
|
| 819 |
if dq is None:
|
| 820 |
dq = deque()
|
|
|
|
| 268 |
return _JOBS.get(job_id)
|
| 269 |
|
| 270 |
|
| 271 |
+
_JOBS_MAX = 2000 # cap retained jobs so a long-lived process can't grow forever
|
| 272 |
+
|
| 273 |
+
|
| 274 |
def _put_job(job: JobState) -> None:
|
| 275 |
with _JOBS_LOCK:
|
| 276 |
_JOBS[job.job_id] = job
|
| 277 |
+
# Bound memory: previously every job + its edit lock was kept for the
|
| 278 |
+
# process lifetime. Dicts preserve insertion order, so evict the oldest
|
| 279 |
+
# (long-finished) jobs once we're over the cap.
|
| 280 |
+
while len(_JOBS) > _JOBS_MAX:
|
| 281 |
+
old_id = next(iter(_JOBS))
|
| 282 |
+
_JOBS.pop(old_id, None)
|
| 283 |
+
_EDIT_LOCKS.pop(old_id, None)
|
| 284 |
|
| 285 |
|
| 286 |
# ----------------------------------------------------------------- preview cache
|
|
|
|
| 822 |
now = time.monotonic()
|
| 823 |
try:
|
| 824 |
with _RL_LOCK:
|
| 825 |
+
# Protect against an IP-rotating flood bloating the table — but evict
|
| 826 |
+
# the STALEST buckets (oldest last-hit), not the whole table. A blunt
|
| 827 |
+
# clear() would reset every honest user's window, and a unique-IP
|
| 828 |
+
# flood could exploit that to wipe limits on demand.
|
| 829 |
if len(_RL_HITS) > _RL_MAX_KEYS:
|
| 830 |
+
try:
|
| 831 |
+
victims = sorted(_RL_HITS.items(),
|
| 832 |
+
key=lambda kv: (kv[1][-1] if kv[1] else 0.0))
|
| 833 |
+
for vk, _dq in victims[: max(1, len(_RL_HITS) // 5)]:
|
| 834 |
+
_RL_HITS.pop(vk, None)
|
| 835 |
+
except Exception: # noqa: BLE001
|
| 836 |
+
_RL_HITS.clear() # last-ditch fallback
|
| 837 |
dq = _RL_HITS.get(bucket)
|
| 838 |
if dq is None:
|
| 839 |
dq = deque()
|