File size: 8,426 Bytes
896528d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 | """
File-based job queue for long-running evaluation tasks.
This module provides persistent job storage that survives HF Spaces timeouts
and restarts. Jobs are stored as individual JSON files for atomic updates
and to avoid file locking issues with concurrent access.
Architecture:
- Each job is stored as a separate file: jobs/{job_id}.json
- Background threads write progress updates to these files
- The Gradio timer polls by reading these files (stateless, fast)
- Old jobs are cleaned up periodically
"""
import json
import os
import time
import uuid
from dataclasses import dataclass, asdict
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Any
# Use /data on HF Spaces (persistent storage), fallback to local directory
if os.path.exists("/data"):
JOBS_DIR = Path("/data/jobs")
else:
JOBS_DIR = Path("jobs")
@dataclass
class Job:
"""Represents an evaluation job."""
job_id: str
model_id: str
model_name: str
max_pairs: int
status: str # "pending", "running", "completed", "failed"
progress: str # Human-readable progress message
progress_pct: float # 0.0 to 1.0
result: Optional[str] # Markdown result when completed
error: Optional[str] # Error message if failed
created_at: str # ISO timestamp
updated_at: str # ISO timestamp
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return asdict(self)
@classmethod
def from_dict(cls, data: dict) -> "Job":
"""Create from dictionary."""
return cls(**data)
def _ensure_jobs_dir():
"""Ensure the jobs directory exists."""
JOBS_DIR.mkdir(parents=True, exist_ok=True)
def _job_path(job_id: str) -> Path:
"""Get the file path for a job."""
return JOBS_DIR / f"{job_id}.json"
def create_job(model_id: str, model_name: str, max_pairs: int) -> Job:
"""
Create a new job and save it to disk.
Args:
model_id: The model identifier
model_name: Display name for the model
max_pairs: Maximum number of pairs to evaluate
Returns:
The created Job object
"""
_ensure_jobs_dir()
now = datetime.now().isoformat()
job = Job(
job_id=str(uuid.uuid4()),
model_id=model_id,
model_name=model_name,
max_pairs=max_pairs,
status="pending",
progress="⏳ Job created, waiting to start...",
progress_pct=0.0,
result=None,
error=None,
created_at=now,
updated_at=now,
)
_save_job(job)
return job
def _save_job(job: Job):
"""
Save a job to disk atomically.
Uses write-to-temp-then-rename pattern to prevent corruption
from concurrent reads or crashes during write.
"""
_ensure_jobs_dir()
job_path = _job_path(job.job_id)
temp_path = job_path.with_suffix(".tmp")
# Write to temp file first
temp_path.write_text(json.dumps(job.to_dict(), indent=2))
# Atomic rename (on POSIX systems)
temp_path.rename(job_path)
def get_job(job_id: str) -> Optional[Job]:
"""
Get a job by ID.
Args:
job_id: The job identifier
Returns:
Job object if found, None otherwise
"""
job_path = _job_path(job_id)
if not job_path.exists():
return None
try:
data = json.loads(job_path.read_text())
return Job.from_dict(data)
except (json.JSONDecodeError, TypeError, KeyError) as e:
# Corrupted job file - remove it
print(f"Warning: Corrupted job file {job_id}, removing: {e}")
try:
job_path.unlink()
except OSError:
pass
return None
def update_job_progress(
job_id: str,
progress: str,
progress_pct: float,
status: str = "running",
):
"""
Update job progress. Called frequently by background thread.
Args:
job_id: The job identifier
progress: Human-readable progress message
progress_pct: Progress percentage (0.0 to 1.0)
status: Job status (usually "running")
"""
job = get_job(job_id)
if job is None:
return
job.status = status
job.progress = progress
job.progress_pct = progress_pct
job.updated_at = datetime.now().isoformat()
_save_job(job)
def complete_job(job_id: str, result: str):
"""
Mark a job as completed with results.
Args:
job_id: The job identifier
result: Markdown-formatted result string
"""
job = get_job(job_id)
if job is None:
return
job.status = "completed"
job.progress = "✅ Evaluation complete!"
job.progress_pct = 1.0
job.result = result
job.updated_at = datetime.now().isoformat()
_save_job(job)
def fail_job(job_id: str, error: str):
"""
Mark a job as failed with an error message.
Args:
job_id: The job identifier
error: Error message
"""
job = get_job(job_id)
if job is None:
return
job.status = "failed"
job.progress = f"❌ Error: {error}"
job.progress_pct = 0.0
job.error = error
job.updated_at = datetime.now().isoformat()
_save_job(job)
def delete_job(job_id: str):
"""
Delete a job file.
Args:
job_id: The job identifier
"""
job_path = _job_path(job_id)
try:
job_path.unlink(missing_ok=True)
except OSError:
pass
def cleanup_old_jobs(max_age_hours: int = 24):
"""
Clean up jobs older than the specified age.
Args:
max_age_hours: Maximum age in hours before cleanup
"""
_ensure_jobs_dir()
cutoff = datetime.now() - timedelta(hours=max_age_hours)
for job_path in JOBS_DIR.glob("*.json"):
try:
data = json.loads(job_path.read_text())
updated_at = datetime.fromisoformat(data.get("updated_at", ""))
if updated_at < cutoff:
print(f"Cleaning up old job: {job_path.name}")
job_path.unlink()
except (json.JSONDecodeError, ValueError, OSError) as e:
# If we can't parse it, it's probably corrupted - remove it
print(f"Removing unparseable job file {job_path.name}: {e}")
try:
job_path.unlink()
except OSError:
pass
def cleanup_stale_jobs(stale_minutes: int = 30):
"""
Clean up jobs that appear to be stale (stuck in running state).
This handles cases where the Space restarted mid-evaluation.
Args:
stale_minutes: Minutes without update before considering stale
"""
_ensure_jobs_dir()
cutoff = datetime.now() - timedelta(minutes=stale_minutes)
for job_path in JOBS_DIR.glob("*.json"):
try:
data = json.loads(job_path.read_text())
status = data.get("status", "")
updated_at = datetime.fromisoformat(data.get("updated_at", ""))
# Only clean up jobs that are stuck in running/pending state
if status in ("running", "pending") and updated_at < cutoff:
print(f"Marking stale job as failed: {job_path.name}")
job_id = data.get("job_id")
if job_id:
fail_job(job_id, "Job timed out or server restarted. Please try again.")
except (json.JSONDecodeError, ValueError, OSError) as e:
pass
def list_jobs(status: Optional[str] = None, limit: int = 100) -> list[Job]:
"""
List jobs, optionally filtered by status.
Args:
status: Filter by status (None for all)
limit: Maximum number of jobs to return
Returns:
List of Job objects, sorted by created_at descending
"""
_ensure_jobs_dir()
jobs = []
for job_path in JOBS_DIR.glob("*.json"):
try:
data = json.loads(job_path.read_text())
job = Job.from_dict(data)
if status is None or job.status == status:
jobs.append(job)
except (json.JSONDecodeError, TypeError, KeyError):
pass
# Sort by created_at descending
jobs.sort(key=lambda j: j.created_at, reverse=True)
return jobs[:limit]
def get_active_job_count() -> int:
"""
Get the count of currently active (running/pending) jobs.
Returns:
Number of active jobs
"""
return len(list_jobs(status="running")) + len(list_jobs(status="pending"))
|