feat(serverless): replace Concurrent jobs with a single Images per run setting

- Remove the separate Concurrent jobs field; rename Jobs per run to
  Images per run (1-16) with a tooltip explaining that Runpod's max
  workers decides how many GPUs run them.
- Backend uses a fixed per-worker limit of 16 outstanding jobs, so all
  images of a run are submitted at once. Unresolved jobs still count
  against it. The limit is constant, so it never has to change while
  jobs are unresolved.
- Legacy 'concurrency' in saved configs and older browser tabs is
  accepted and ignored/dropped; it no longer triggers the busy guard.
- Revert the enable toggle and show the reason when the backend rejects it.
- Trim a pasted api.runpod.ai/v2/<id> URL to the endpoint ID; plainer
  endpoint-ID error otherwise.
- Confirm before a type change deletes a saved Runpod API key.
- Update docs and tests.
This commit is contained in:
Robert Wojciechowski
2026-10-13 21:33:02 +00:00
committed by r
parent 7060e31edb
commit 06be0e5dee
13 changed files with 358 additions and 95 deletions
+4 -8
View File
@@ -218,8 +218,7 @@ def _bulk_serverless_changes(old_workers, new_workers):
if new is None or new.get("type") != "serverless":
_ensure_pool_idle(worker_id)
secret_ids.add(worker_id)
elif (old.get("endpoint_id") != new.get("endpoint_id")
or old.get("concurrency", 1) != new.get("concurrency", 1)):
elif old.get("endpoint_id") != new.get("endpoint_id"):
_ensure_pool_idle(worker_id)
elif new is not None and new.get("type") == "serverless":
secret_ids.add(worker_id)
@@ -401,7 +400,8 @@ async def _update_worker_endpoint(request, secret_changes):
candidate["type"] = "serverless"
candidate.setdefault("provider", "runpod")
candidate.setdefault("enabled", False)
for field in ("provider", "endpoint_id", "jobs_per_run", "concurrency", "timeout_seconds", "enabled", "name"):
# Legacy "concurrency" is accepted (see _SERVERLESS_CONFIG_FIELDS) but ignored.
for field in ("provider", "endpoint_id", "jobs_per_run", "timeout_seconds", "enabled", "name"):
if field in data:
candidate[field] = data[field]
candidate = _clean_serverless_worker(candidate)
@@ -424,12 +424,8 @@ async def _update_worker_endpoint(request, secret_changes):
current_type == "serverless"
and existing.get("endpoint_id") != validated.get("endpoint_id")
)
concurrency_changed = (
current_type == "serverless"
and existing.get("concurrency", 1) != validated.get("concurrency", 1)
)
if current_type == "serverless" and (
endpoint_changed or concurrency_changed or next_secret != prior_secret
endpoint_changed or next_secret != prior_secret
):
_ensure_pool_idle(worker_id)
+17 -12
View File
@@ -12,6 +12,15 @@ from .serverless_state import (
)
# Fixed per-worker cap on this master's outstanding Runpod jobs; equals the
# Images per run maximum (MAX_JOBS_PER_RUN in utils/serverless_config.py), so
# every image of a run is submitted at once. Deliberately not configurable or
# derived from jobs_per_run: unresolved jobs from earlier runs (ambiguous
# submissions, unconfirmed cancels) still count against it, and a constant
# never has to change while such jobs exist.
PER_WORKER_JOB_LIMIT = 16
async def decode_results(output, manifest):
from .serverless_media import collect_images
import torch
@@ -85,7 +94,7 @@ class ServerlessManager:
self._check_master(run)
outstanding = sum(belongs_to_pool(record, pool_id) and occupies_capacity(record)
for record in self.ledger.all())
if outstanding < run["worker"]["concurrency"]:
if outstanding < PER_WORKER_JOB_LIMIT:
# Claim atomically before any await that could submit a billed job.
self.ledger.update(run["record_id"], status="SUBMITTING", updated_at=time.time())
return
@@ -122,18 +131,14 @@ class ServerlessManager:
return result
def _semaphore(self, worker):
# The limit is a constant, so it never has to be swapped while this
# worker still has unresolved jobs.
pool_id = worker.get("pool_id", worker["id"])
limit = worker["concurrency"]
current = self.pool_limits.get(pool_id)
if current is None:
current = (limit, asyncio.Semaphore(limit))
self.pool_limits[pool_id] = current
elif current[0] != limit:
if self.pool_is_active(pool_id):
raise ValueError("Wait for this serverless worker's current jobs before changing concurrency")
current = (limit, asyncio.Semaphore(limit))
self.pool_limits[pool_id] = current
return current[1]
semaphore = self.pool_limits.get(pool_id)
if semaphore is None:
semaphore = asyncio.Semaphore(PER_WORKER_JOB_LIMIT)
self.pool_limits[pool_id] = semaphore
return semaphore
def ensure_capacity(self, count):
if sum(not task.done() for task in self.tasks.values()) + count > 64: