Rthur2003 commited on
Commit
db51cdb
·
1 Parent(s): 61387f5

feat: background analysis jobs servisi ile fair queueing ve caching eklendi

Browse files
Files changed (1) hide show
  1. app/services/analysis_jobs.py +232 -26
app/services/analysis_jobs.py CHANGED
@@ -1,26 +1,41 @@
1
  """
2
- Background analysis jobs with real, per-step progress.
 
3
 
4
  POST /api/analyze/jobs starts a job and returns its id; the client polls
5
- GET /api/analyze/jobs/{id}. Each pipeline step reports when it starts and
6
- finishes, so the waiting screen shows what the server is actually doing
7
- instead of a simulated progress bar. A job also survives the client
8
- leaving: a reloaded page can resume polling the same id.
9
 
10
- State lives in process memory — the Space runs one replica, and a job
11
- only needs to outlive the request that started it, not a restart.
 
 
 
 
12
  """
13
 
14
  from __future__ import annotations
15
 
16
  import asyncio
 
17
  import time
18
  import uuid
 
 
19
  from dataclasses import dataclass, field
20
- from typing import Any, Awaitable, Callable, Dict, List, Optional
21
 
22
  JOB_TTL_SEC = 15 * 60
23
  MAX_JOBS = 200
 
 
 
 
 
 
 
24
 
25
  STEP_PENDING = "pending"
26
  STEP_RUNNING = "running"
@@ -32,13 +47,24 @@ FILE_STEPS = ["features", "vocals", "wav2vec2", "clap", "fst", "xai", "meta"]
32
  URL_STEPS = ["download", *FILE_STEPS]
33
 
34
 
 
 
 
 
 
 
 
 
35
  class Progress:
36
- """Ordered step states for one analysis. A no-op sink is fine too."""
37
 
38
  def __init__(self, steps: List[str]) -> None:
39
  self._steps: Dict[str, str] = {s: STEP_PENDING for s in steps}
40
  self._started: Dict[str, float] = {}
41
  self._took: Dict[str, float] = {}
 
 
 
42
 
43
  def set(self, step: str, state: str) -> None:
44
  if step not in self._steps:
@@ -54,6 +80,15 @@ class Progress:
54
  for s in steps:
55
  self.set(s, STEP_RUNNING)
56
 
 
 
 
 
 
 
 
 
 
57
  def snapshot(self) -> List[Dict[str, Any]]:
58
  out: List[Dict[str, Any]] = []
59
  for step, state in self._steps.items():
@@ -64,72 +99,243 @@ class Progress:
64
  return out
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  @dataclass
68
  class Job:
69
  id: str
70
  source_type: str
71
  progress: Progress
 
72
  created: float = field(default_factory=time.time)
73
  updated: float = field(default_factory=time.time)
74
- status: str = "running" # running | done | error
 
 
75
  response: Optional[Dict[str, Any]] = None
76
  task: Optional[asyncio.Task] = None
77
 
78
  def to_dict(self) -> Dict[str, Any]:
79
- return {
80
  "jobId": self.id,
81
  "sourceType": self.source_type,
82
  "status": self.status,
83
  "steps": self.progress.snapshot(),
84
- "elapsedSec": round((self.updated if self.status != "running" else time.time()) - self.created, 2),
85
  "response": self.response,
86
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
 
89
  class JobStore:
90
  def __init__(self) -> None:
91
  self._jobs: Dict[str, Job] = {}
 
92
 
93
  def _prune(self) -> None:
94
  cutoff = time.time() - JOB_TTL_SEC
95
- for job_id in [j.id for j in self._jobs.values() if j.status != "running" and j.updated < cutoff]:
96
  del self._jobs[job_id]
97
- # Hard cap: drop the oldest finished jobs first.
98
  if len(self._jobs) > MAX_JOBS:
99
- finished = sorted((j for j in self._jobs.values() if j.status != "running"), key=lambda j: j.updated)
100
  for job in finished[: len(self._jobs) - MAX_JOBS]:
101
  del self._jobs[job.id]
102
 
 
 
 
103
  def running_count(self) -> int:
104
  return sum(1 for j in self._jobs.values() if j.status == "running")
105
 
106
- def start(
107
- self,
108
- source_type: str,
109
- steps: List[str],
110
- work: Callable[[Progress], Awaitable[Dict[str, Any]]],
111
- ) -> Job:
112
  self._prune()
113
- job = Job(id=uuid.uuid4().hex, source_type=source_type, progress=Progress(steps))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  self._jobs[job.id] = job
 
 
115
 
116
  async def runner() -> None:
117
  try:
118
- job.response = await work(job.progress)
119
- job.status = "error" if job.response.get("errors") and not job.response.get("result") else "done"
 
 
 
 
 
 
120
  except Exception: # noqa: BLE001 — the job must always settle
 
121
  job.response = {"result": None, "warnings": [], "errors": ["internal_error"]}
122
  job.status = "error"
123
  finally:
124
  job.updated = time.time()
 
 
 
125
 
126
  # Keep a reference: asyncio only holds weak references to tasks.
127
  job.task = asyncio.create_task(runner())
128
  return job
129
 
130
- def get(self, job_id: str) -> Optional[Job]:
131
  self._prune()
132
- return self._jobs.get(job_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
 
135
  job_store = JobStore()
 
1
  """
2
+ Background analysis jobs: a fair queue in front of the CPU, real per-step
3
+ progress, cancellation, and a small result cache.
4
 
5
  POST /api/analyze/jobs starts a job and returns its id; the client polls
6
+ GET /api/analyze/jobs/{id}. The Space has two vCPUs and one analysis
7
+ already keeps both busy, so jobs take the CPU one at a time (CPU_SLOTS) in
8
+ arrival order and everyone else sees their place in the queue. Network
9
+ waits (YouTube download, the external FST model) happen outside that slot.
10
 
11
+ A job belongs to whoever polls it. One that nobody has polled for a while
12
+ when its turn comes (the tab was closed) is dropped instead of analysed.
13
+ A reloaded page resumes polling the same id, so it isn't dropped.
14
+
15
+ State lives in process memory: the Space runs one replica, and a job only
16
+ has to outlive the request that started it, not a restart.
17
  """
18
 
19
  from __future__ import annotations
20
 
21
  import asyncio
22
+ import os
23
  import time
24
  import uuid
25
+ from collections import OrderedDict
26
+ from contextlib import asynccontextmanager
27
  from dataclasses import dataclass, field
28
+ from typing import Any, AsyncIterator, Awaitable, Callable, Dict, List, Optional
29
 
30
  JOB_TTL_SEC = 15 * 60
31
  MAX_JOBS = 200
32
+ # Jobs waiting or running at once; beyond this, new ones get 503.
33
+ MAX_PENDING = int(os.getenv("AURIS_MAX_PENDING", "24"))
34
+ CPU_SLOTS = int(os.getenv("AURIS_CPU_SLOTS", "1"))
35
+ # Unpolled this long when its turn comes: the visitor has gone.
36
+ ABANDON_SEC = 180.0
37
+ CACHE_SIZE = 64
38
+ CACHE_TTL_SEC = 6 * 3600
39
 
40
  STEP_PENDING = "pending"
41
  STEP_RUNNING = "running"
 
47
  URL_STEPS = ["download", *FILE_STEPS]
48
 
49
 
50
+ class JobCancelled(Exception):
51
+ """Raised inside a pipeline when its job was cancelled or abandoned."""
52
+
53
+ def __init__(self, code: str = "cancelled") -> None:
54
+ super().__init__(code)
55
+ self.code = code
56
+
57
+
58
  class Progress:
59
+ """Ordered step states for one analysis, plus its cancel flag."""
60
 
61
  def __init__(self, steps: List[str]) -> None:
62
  self._steps: Dict[str, str] = {s: STEP_PENDING for s in steps}
63
  self._started: Dict[str, float] = {}
64
  self._took: Dict[str, float] = {}
65
+ self.cancelled = False
66
+ # Returns True when nobody is waiting for the result any more.
67
+ self.gone: Callable[[], Awaitable[bool]] = _never
68
 
69
  def set(self, step: str, state: str) -> None:
70
  if step not in self._steps:
 
80
  for s in steps:
81
  self.set(s, STEP_RUNNING)
82
 
83
+ def skip_pending(self) -> None:
84
+ for step, state in self._steps.items():
85
+ if state in (STEP_PENDING, STEP_RUNNING):
86
+ self._steps[step] = STEP_SKIPPED
87
+
88
+ def check(self) -> None:
89
+ if self.cancelled:
90
+ raise JobCancelled("cancelled")
91
+
92
  def snapshot(self) -> List[Dict[str, Any]]:
93
  out: List[Dict[str, Any]] = []
94
  for step, state in self._steps.items():
 
99
  return out
100
 
101
 
102
+ async def _never() -> bool:
103
+ return False
104
+
105
+
106
+ class CpuGate:
107
+ """First come, first served access to the CPU-heavy stage."""
108
+
109
+ def __init__(self, slots: int) -> None:
110
+ self._slots = max(1, slots)
111
+ self._busy = 0
112
+ self._waiting: List[str] = []
113
+ self._events: Dict[str, asyncio.Event] = {}
114
+ # Mean seconds a job holds the CPU; None until one has finished.
115
+ self._mean: Optional[float] = None
116
+
117
+ def position(self, ticket: str) -> Optional[int]:
118
+ """1-based place in the queue, or None when not waiting."""
119
+ try:
120
+ return self._waiting.index(ticket) + 1
121
+ except ValueError:
122
+ return None
123
+
124
+ def estimated_wait(self, position: int) -> Optional[float]:
125
+ if self._mean is None:
126
+ return None
127
+ # Everyone ahead, plus whatever is on the CPU now (half done on average).
128
+ return round(((position - 1) / self._slots + (0.5 if self._busy else 0)) * self._mean, 1)
129
+
130
+ @property
131
+ def waiting(self) -> int:
132
+ return len(self._waiting)
133
+
134
+ @property
135
+ def busy(self) -> int:
136
+ return self._busy
137
+
138
+ def _wake(self) -> None:
139
+ while self._waiting and self._busy < self._slots:
140
+ ticket = self._waiting.pop(0)
141
+ self._busy += 1
142
+ self._events.pop(ticket).set()
143
+
144
+ @asynccontextmanager
145
+ async def slot(self, ticket: str, progress: Optional[Progress] = None) -> AsyncIterator[None]:
146
+ event = asyncio.Event()
147
+ self._events[ticket] = event
148
+ self._waiting.append(ticket)
149
+ self._wake()
150
+ try:
151
+ await event.wait()
152
+ except BaseException:
153
+ if ticket in self._waiting:
154
+ self._waiting.remove(ticket)
155
+ self._events.pop(ticket, None)
156
+ else:
157
+ self._release()
158
+ raise
159
+ started = time.monotonic()
160
+ try:
161
+ if progress is not None:
162
+ progress.check()
163
+ if await progress.gone():
164
+ raise JobCancelled("job_abandoned")
165
+ yield
166
+ finally:
167
+ held = time.monotonic() - started
168
+ if held > 1.0:
169
+ self._mean = held if self._mean is None else self._mean * 0.7 + held * 0.3
170
+ self._release()
171
+
172
+ def _release(self) -> None:
173
+ self._busy = max(0, self._busy - 1)
174
+ self._wake()
175
+
176
+
177
+ cpu_gate = CpuGate(CPU_SLOTS)
178
+
179
+
180
+ class ResultCache:
181
+ """Recent successful responses by content key (same file, same link)."""
182
+
183
+ def __init__(self, size: int, ttl: float) -> None:
184
+ self._size = size
185
+ self._ttl = ttl
186
+ self._items: "OrderedDict[str, tuple[float, Dict[str, Any]]]" = OrderedDict()
187
+
188
+ def get(self, key: Optional[str]) -> Optional[Dict[str, Any]]:
189
+ if not key or key not in self._items:
190
+ return None
191
+ stored, value = self._items[key]
192
+ if time.time() - stored > self._ttl:
193
+ del self._items[key]
194
+ return None
195
+ self._items.move_to_end(key)
196
+ return value
197
+
198
+ def put(self, key: Optional[str], value: Dict[str, Any]) -> None:
199
+ if not key or value.get("errors") or not value.get("result"):
200
+ return
201
+ self._items[key] = (time.time(), value)
202
+ self._items.move_to_end(key)
203
+ while len(self._items) > self._size:
204
+ self._items.popitem(last=False)
205
+
206
+
207
+ result_cache = ResultCache(CACHE_SIZE, CACHE_TTL_SEC)
208
+
209
+
210
  @dataclass
211
  class Job:
212
  id: str
213
  source_type: str
214
  progress: Progress
215
+ key: Optional[str] = None
216
  created: float = field(default_factory=time.time)
217
  updated: float = field(default_factory=time.time)
218
+ last_seen: float = field(default_factory=time.monotonic)
219
+ status: str = "queued" # queued | running | done | error
220
+ cached: bool = False
221
  response: Optional[Dict[str, Any]] = None
222
  task: Optional[asyncio.Task] = None
223
 
224
  def to_dict(self) -> Dict[str, Any]:
225
+ data: Dict[str, Any] = {
226
  "jobId": self.id,
227
  "sourceType": self.source_type,
228
  "status": self.status,
229
  "steps": self.progress.snapshot(),
230
+ "elapsedSec": round((self.updated if self.status in ("done", "error") else time.time()) - self.created, 2),
231
  "response": self.response,
232
  }
233
+ position = cpu_gate.position(self.id)
234
+ if self.status == "queued" and position is not None:
235
+ data["queue"] = {"position": position, "estimatedWaitSec": cpu_gate.estimated_wait(position)}
236
+ if self.cached:
237
+ data["cached"] = True
238
+ return data
239
+
240
+
241
+ class QueueFull(Exception):
242
+ pass
243
+
244
+
245
+ Work = Callable[[Job], Awaitable[Dict[str, Any]]]
246
 
247
 
248
  class JobStore:
249
  def __init__(self) -> None:
250
  self._jobs: Dict[str, Job] = {}
251
+ self._inflight: Dict[str, str] = {}
252
 
253
  def _prune(self) -> None:
254
  cutoff = time.time() - JOB_TTL_SEC
255
+ for job_id in [j.id for j in self._jobs.values() if j.status in ("done", "error") and j.updated < cutoff]:
256
  del self._jobs[job_id]
 
257
  if len(self._jobs) > MAX_JOBS:
258
+ finished = sorted((j for j in self._jobs.values() if j.status in ("done", "error")), key=lambda j: j.updated)
259
  for job in finished[: len(self._jobs) - MAX_JOBS]:
260
  del self._jobs[job.id]
261
 
262
+ def pending_count(self) -> int:
263
+ return sum(1 for j in self._jobs.values() if j.status in ("queued", "running"))
264
+
265
  def running_count(self) -> int:
266
  return sum(1 for j in self._jobs.values() if j.status == "running")
267
 
268
+ def start(self, source_type: str, steps: List[str], work: Work, key: Optional[str] = None) -> Job:
269
+ """Queue a job; the same file or link already in flight is shared, a recent one is served from cache."""
 
 
 
 
270
  self._prune()
271
+ if key and key in self._inflight:
272
+ shared = self._jobs.get(self._inflight[key])
273
+ if shared and shared.status in ("queued", "running"):
274
+ shared.last_seen = time.monotonic()
275
+ return shared
276
+
277
+ job = Job(id=uuid.uuid4().hex, source_type=source_type, progress=Progress(steps), key=key)
278
+ hit = result_cache.get(key)
279
+ if hit is not None:
280
+ job.status, job.cached, job.response = "done", True, hit
281
+ job.progress.skip_pending()
282
+ for step in steps:
283
+ job.progress.set(step, STEP_DONE)
284
+ self._jobs[job.id] = job
285
+ return job
286
+
287
+ if self.pending_count() >= MAX_PENDING:
288
+ raise QueueFull()
289
+
290
+ async def gone() -> bool:
291
+ return time.monotonic() - job.last_seen > ABANDON_SEC
292
+
293
+ job.progress.gone = gone
294
  self._jobs[job.id] = job
295
+ if key:
296
+ self._inflight[key] = job.id
297
 
298
  async def runner() -> None:
299
  try:
300
+ job.response = await work(job)
301
+ failed = bool(job.response.get("errors")) and not job.response.get("result")
302
+ job.status = "error" if failed else "done"
303
+ result_cache.put(key, job.response)
304
+ except JobCancelled as stop:
305
+ job.progress.skip_pending()
306
+ job.response = {"result": None, "warnings": [], "errors": [stop.code]}
307
+ job.status = "error"
308
  except Exception: # noqa: BLE001 — the job must always settle
309
+ job.progress.skip_pending()
310
  job.response = {"result": None, "warnings": [], "errors": ["internal_error"]}
311
  job.status = "error"
312
  finally:
313
  job.updated = time.time()
314
+ if key and self._inflight.get(key) == job.id:
315
+ del self._inflight[key]
316
+ job.task = None
317
 
318
  # Keep a reference: asyncio only holds weak references to tasks.
319
  job.task = asyncio.create_task(runner())
320
  return job
321
 
322
+ def get(self, job_id: str, touch: bool = True) -> Optional[Job]:
323
  self._prune()
324
+ job = self._jobs.get(job_id)
325
+ if job is not None and touch:
326
+ job.last_seen = time.monotonic()
327
+ return job
328
+
329
+ def cancel(self, job_id: str) -> Optional[Job]:
330
+ job = self._jobs.get(job_id)
331
+ if job is None:
332
+ return None
333
+ if job.status in ("queued", "running"):
334
+ job.progress.cancelled = True
335
+ # A job still waiting for the CPU never gets there.
336
+ if job.status == "queued" and job.task is not None:
337
+ job.task.cancel()
338
+ return job
339
 
340
 
341
  job_store = JobStore()