Stable Diffusion: tolerate transient status-poll failures; runpod-lazy v5 in-place model switching (homelab)
| @@ -5,8 +5,10 @@ SillyTavern's fallback chain points at this proxy as a normal ComfyUI server. | |||
| 5 | Models come from a CATALOG pushed by the ST UI: entries of | 5 | Models come from a CATALOG pushed by the ST UI: entries of |
| 6 | {"name": display, "value": model filename, "files": [{"dest": "unet/x.gguf", "url": "..."}]} | 6 | {"name": display, "value": model filename, "files": [{"dest": "unet/x.gguf", "url": "..."}]} |
| 7 | The pod downloads exactly the selected entry's files at boot (boot-models.py | 7 | The pod downloads exactly the selected entry's files at boot (boot-models.py |
| 8 | reads MODEL_MANIFEST). Changing the active model with a running pod recreates | 8 | reads MODEL_MANIFEST). Changing the active model with a RUNNING pod downloads |
| 9 | it (fresh disk = old model gone, only the new one downloads). | 9 | the new files IN PLACE through the pod's model-manager (port 8189; LRU-evicts |
| 10 | old models if the disk runs short) - the pod is only recreated when it runs an | ||
| 11 | old image without the manager. | ||
| 10 | 12 | ||
| 11 | Control API (CORS-enabled; ST warmup UI + runpod-pod.sh CLI): | 13 | Control API (CORS-enabled; ST warmup UI + runpod-pod.sh CLI): |
| 12 | GET /lazy/status -> {"state": red|orange|green, "model": ..., ...} | 14 | GET /lazy/status -> {"state": red|orange|green, "model": ..., ...} |
| @@ -45,11 +47,12 @@ CATALOG_FILE = os.environ.get('CATALOG_FILE', '/app/catalog.json') | |||
| 45 | 47 | ||
| 46 | state = { | 48 | state = { |
| 47 | 'pod_id': None, | 49 | 'pod_id': None, |
| 48 | 'model': None, # catalog `value` the pod was provisioned for | 50 | 'model': None, # values_key of the models the pod holds |
| 49 | 'phase': 'red', # red | orange | green | 51 | 'phase': 'red', # red | orange | green |
| 50 | 'gpu': None, | 52 | 'gpu': None, |
| 51 | 'since': time.time(), | 53 | 'since': time.time(), |
| 52 | 'last': time.monotonic(), | 54 | 'last': time.monotonic(), |
| 55 | 'ensuring': 0, # active ensure_pod waiters (status skips probing then) | ||
| 53 | 'lock': threading.Lock(), | 56 | 'lock': threading.Lock(), |
| 54 | } | 57 | } |
| 55 | 58 | ||
| @@ -91,6 +94,19 @@ def values_key(values): | |||
| 91 | return '+'.join(sorted(values)) if values else '' | 94 | return '+'.join(sorted(values)) if values else '' |
| 92 | 95 | ||
| 93 | 96 | ||
| 97 | def key_values(key): | ||
| 98 | return set(k for k in (key or '').split('+') if k) | ||
| 99 | |||
| 100 | |||
| 101 | def needed_files(values): | ||
| 102 | files = [] | ||
| 103 | for value in values or []: | ||
| 104 | entry = catalog_entry(value) | ||
| 105 | if entry: | ||
| 106 | files.extend(entry.get('files') or []) | ||
| 107 | return files | ||
| 108 | |||
| 109 | |||
| 94 | def api(method, path, body=None): | 110 | def api(method, path, body=None): |
| 95 | req = urllib.request.Request( | 111 | req = urllib.request.Request( |
| 96 | f'https://rest.runpod.io/v1{path}', | 112 | f'https://rest.runpod.io/v1{path}', |
| @@ -131,9 +147,10 @@ def create_pod(values): | |||
| 131 | 'gpuCount': 1, | 147 | 'gpuCount': 1, |
| 132 | 'cloudType': CLOUD_TYPE, | 148 | 'cloudType': CLOUD_TYPE, |
| 133 | 'containerDiskInGb': 80, | 149 | 'containerDiskInGb': 80, |
| 134 | 'ports': ['8188/http'], | 150 | 'ports': ['8188/http', '8189/http'], |
| 135 | 'env': env, | 151 | 'env': env, |
| 136 | 'dockerStartCmd': ['bash', '-c', f'python3 /boot-models.py && cd /comfyui && exec python main.py {COMFY_ARGS}'], | 152 | # The model-manager subshell tolerates old images without the script. |
| 153 | 'dockerStartCmd': ['bash', '-c', f'python3 /boot-models.py && (python3 /model-manager.py &) && cd /comfyui && exec python main.py {COMFY_ARGS}'], | ||
| 137 | } | 154 | } |
| 138 | if DATACENTERS: | 155 | if DATACENTERS: |
| 139 | body['dataCenterIds'] = DATACENTERS | 156 | body['dataCenterIds'] = DATACENTERS |
| @@ -146,6 +163,17 @@ def pod_url(pod_id): | |||
| 146 | return f'https://{pod_id}-8188.proxy.runpod.net' | 163 | return f'https://{pod_id}-8188.proxy.runpod.net' |
| 147 | 164 | ||
| 148 | 165 | ||
| 166 | def mm_request(pod_id, method, path, obj=None, timeout=15): | ||
| 167 | """Calls the in-pod model manager (port 8189).""" | ||
| 168 | req = urllib.request.Request( | ||
| 169 | f'https://{pod_id}-8189.proxy.runpod.net{path}', | ||
| 170 | json.dumps(obj).encode() if obj is not None else None, | ||
| 171 | {'User-Agent': UA, 'Content-Type': 'application/json'}, | ||
| 172 | method=method) | ||
| 173 | with urllib.request.urlopen(req, timeout=timeout) as resp: | ||
| 174 | return json.loads(resp.read() or b'{}') | ||
| 175 | |||
| 176 | |||
| 149 | UA = 'Mozilla/5.0 (compatible; runpod-lazy-proxy)' | 177 | UA = 'Mozilla/5.0 (compatible; runpod-lazy-proxy)' |
| 150 | 178 | ||
| 151 | 179 | ||
| @@ -171,36 +199,38 @@ def terminate(pod_id): | |||
| 171 | log('terminate failed:', err) | 199 | log('terminate failed:', err) |
| 172 | 200 | ||
| 173 | 201 | ||
| 174 | def ensure_pod(values=None, wait=True): | 202 | def _create_pod_with_retries(values): |
| 175 | """Ensures a pod provisioned for catalog values (None = active selection).""" | ||
| 176 | values = values or active_values() | ||
| 177 | key = values_key(values) | ||
| 178 | with state['lock']: | ||
| 179 | pod_id, have, gpu = (state['pod_id'], state['model'], state['gpu']) | ||
| 180 | if not pod_id: | ||
| 181 | pod_id, have, gpu = find_pod() | ||
| 182 | # Only recreate when the pod's model set is KNOWN and different. An | ||
| 183 | # unknown set (e.g. env not reported) must not churn pods. | ||
| 184 | if pod_id and key and have and have != key: | ||
| 185 | log(f'pod has models={have}, need {key} - recreating') | ||
| 186 | terminate(pod_id) | ||
| 187 | pod_id, have, gpu = None, None, None | ||
| 188 | if not pod_id: | ||
| 189 | state['phase'] = 'orange' | 203 | state['phase'] = 'orange' |
| 190 | state['since'] = time.time() | 204 | state['since'] = time.time() |
| 191 | last_err = None | 205 | last_err = None |
| 192 | for attempt in range(3): | 206 | for attempt in range(3): |
| 193 | try: | 207 | try: |
| 194 | pod_id, gpu = create_pod(values) | 208 | return create_pod(values) |
| 195 | have = key | ||
| 196 | break | ||
| 197 | except urllib.error.HTTPError as err: | 209 | except urllib.error.HTTPError as err: |
| 198 | last_err = err.read().decode()[:200] | 210 | last_err = err.read().decode()[:200] |
| 199 | log(f'create_pod attempt {attempt + 1} failed:', last_err) | 211 | log(f'create_pod attempt {attempt + 1} failed:', last_err) |
| 200 | time.sleep(5) | 212 | time.sleep(5) |
| 201 | if not pod_id: | ||
| 202 | state['phase'] = 'red' | 213 | state['phase'] = 'red' |
| 203 | raise RuntimeError(f'could not create pod: {last_err}') | 214 | raise RuntimeError(f'could not create pod: {last_err}') |
| 215 | |||
| 216 | |||
| 217 | def ensure_pod(values=None, wait=True): | ||
| 218 | """Ensures a pod holding the catalog values (None = active selection). | ||
| 219 | |||
| 220 | An existing pod gets missing models downloaded IN PLACE through the in-pod | ||
| 221 | model manager; recreation only happens for old-image pods without one.""" | ||
| 222 | values = values or active_values() | ||
| 223 | key = values_key(values) | ||
| 224 | files = needed_files(values) | ||
| 225 | dests = [f['dest'].lstrip('/') for f in files] | ||
| 226 | created = False | ||
| 227 | with state['lock']: | ||
| 228 | pod_id, have, gpu = (state['pod_id'], state['model'], state['gpu']) | ||
| 229 | if not pod_id: | ||
| 230 | pod_id, have, gpu = find_pod() | ||
| 231 | if not pod_id: | ||
| 232 | pod_id, gpu = _create_pod_with_retries(values) | ||
| 233 | have, created = key, True | ||
| 204 | state['pod_id'], state['model'], state['gpu'] = pod_id, have, gpu | 234 | state['pod_id'], state['model'], state['gpu'] = pod_id, have, gpu |
| 205 | state['last'] = time.monotonic() | 235 | state['last'] = time.monotonic() |
| 206 | 236 | ||
| @@ -208,21 +238,63 @@ def ensure_pod(values=None, wait=True): | |||
| 208 | state['phase'] = 'orange' | 238 | state['phase'] = 'orange' |
| 209 | if not wait: | 239 | if not wait: |
| 210 | return pod_id | 240 | return pod_id |
| 241 | # Freshly created pods boot with the right manifest; existing pods need an | ||
| 242 | # /ensure pushed once the manager answers. | ||
| 243 | ensured = created or not files | ||
| 244 | no_manager_strikes = 0 | ||
| 245 | state['ensuring'] += 1 | ||
| 246 | try: | ||
| 211 | deadline = time.monotonic() + START_TIMEOUT | 247 | deadline = time.monotonic() + START_TIMEOUT |
| 212 | while time.monotonic() < deadline: | 248 | while time.monotonic() < deadline: |
| 213 | # Booting/downloading counts as activity, else the reaper would kill a | 249 | # Booting/downloading counts as activity, else the reaper would |
| 214 | # warming pod mid-download. | 250 | # kill a warming pod mid-download. |
| 215 | state['last'] = time.monotonic() | 251 | state['last'] = time.monotonic() |
| 216 | if upstream_ready(pod_id): | 252 | if not ensured: |
| 253 | try: | ||
| 254 | mm_request(pod_id, 'POST', '/ensure', {'files': files}) | ||
| 255 | ensured = True | ||
| 256 | state['model'] = values_key(key_values(state['model']) | set(values)) | ||
| 257 | log(f'in-place ensure requested: {key}') | ||
| 258 | except Exception: | ||
| 259 | pass # manager still booting, or old image without one | ||
| 260 | ready = upstream_ready(pod_id) | ||
| 261 | models_ok = True | ||
| 262 | if not created and files and ensured: | ||
| 263 | try: | ||
| 264 | mm = mm_request(pod_id, 'GET', '/status', timeout=10) | ||
| 265 | errs = {d: e for d, e in (mm.get('errors') or {}).items() if d in dests} | ||
| 266 | if errs: | ||
| 267 | raise RuntimeError(f'model download failed: {errs}') | ||
| 268 | models_ok = not (set(dests) - set(mm.get('present') or [])) | ||
| 269 | except RuntimeError: | ||
| 270 | raise | ||
| 271 | except Exception: | ||
| 272 | models_ok = False | ||
| 273 | if ready and not ensured: | ||
| 274 | no_manager_strikes += 1 | ||
| 275 | if (no_manager_strikes >= 3 and key and state['model'] and state['model'] != key): | ||
| 276 | # Comfy is up but there is no manager (old image): last resort. | ||
| 277 | log(f'pod has models={state["model"]}, need {key} - recreating (no model manager)') | ||
| 278 | with state['lock']: | ||
| 279 | terminate(pod_id) | ||
| 280 | pod_id, gpu = _create_pod_with_retries(values) | ||
| 281 | state.update({'pod_id': pod_id, 'model': key, 'gpu': gpu, 'last': time.monotonic()}) | ||
| 282 | created, ensured = True, True | ||
| 283 | continue | ||
| 284 | if ready and models_ok: | ||
| 217 | if state['phase'] != 'green': | 285 | if state['phase'] != 'green': |
| 218 | state['phase'] = 'green' | 286 | state['phase'] = 'green' |
| 219 | state['since'] = time.time() | 287 | state['since'] = time.time() |
| 220 | log('pod ready:', pod_id) | 288 | log('pod ready:', pod_id) |
| 221 | return pod_id | 289 | return pod_id |
| 290 | if state['phase'] == 'green': | ||
| 291 | state['phase'] = 'orange' # in-place download in progress | ||
| 222 | if state['pod_id'] != pod_id: | 292 | if state['pod_id'] != pod_id: |
| 223 | raise RuntimeError('pod replaced while waiting') | 293 | raise RuntimeError('pod replaced while waiting') |
| 224 | time.sleep(5) | 294 | time.sleep(5) |
| 225 | raise RuntimeError('pod not ready within START_TIMEOUT') | 295 | raise RuntimeError('pod not ready within START_TIMEOUT') |
| 296 | finally: | ||
| 297 | state['ensuring'] -= 1 | ||
| 226 | 298 | ||
| 227 | 299 | ||
| 228 | def match_workflow_models(workflow_bytes): | 300 | def match_workflow_models(workflow_bytes): |
| @@ -254,10 +326,15 @@ def idle_reaper(): | |||
| 254 | 326 | ||
| 255 | def status_body(): | 327 | def status_body(): |
| 256 | pod_id = state['pod_id'] | 328 | pod_id = state['pod_id'] |
| 257 | if pod_id and state['phase'] != 'green' and upstream_ready(pod_id): | 329 | if state['ensuring'] > 0: |
| 330 | # An ensure_pod waiter owns the phase; probing here could report green | ||
| 331 | # mid-download or block the status reply on a dead pod's proxy URL. | ||
| 332 | pass | ||
| 333 | elif pod_id: | ||
| 334 | if state['phase'] != 'green' and upstream_ready(pod_id): | ||
| 258 | state['phase'] = 'green' | 335 | state['phase'] = 'green' |
| 259 | state['since'] = time.time() | 336 | state['since'] = time.time() |
| 260 | if not pod_id: | 337 | else: |
| 261 | found, have, gpu = find_pod() | 338 | found, have, gpu = find_pod() |
| 262 | if found: | 339 | if found: |
| 263 | state.update({'pod_id': found, 'model': have, 'gpu': gpu}) | 340 | state.update({'pod_id': found, 'model': have, 'gpu': gpu}) |
| @@ -327,10 +404,10 @@ class Proxy(BaseHTTPRequestHandler): | |||
| 327 | catalog = {'models': payload.get('models', []), 'active': [a for a in (active or []) if a]} | 404 | catalog = {'models': payload.get('models', []), 'active': [a for a in (active or []) if a]} |
| 328 | save_catalog(catalog) | 405 | save_catalog(catalog) |
| 329 | log(f"catalog updated: {len(catalog['models'])} models, active={catalog['active']}") | 406 | log(f"catalog updated: {len(catalog['models'])} models, active={catalog['active']}") |
| 330 | # Re-provision proactively when the active model changed under a live pod | 407 | # Fetch missing models proactively when the active set changed |
| 331 | # (known-model pods only - never churn a pod of unknown provenance). | 408 | # under a live pod (in place; recreate only for old images). |
| 332 | if state['pod_id'] and catalog['active'] and state['model'] and state['model'] != values_key(catalog['active']): | 409 | if state['pod_id'] and catalog['active'] and state['model'] and not (set(catalog['active']) <= key_values(state['model'])): |
| 333 | log('active model set changed - re-provisioning pod') | 410 | log('active model set changed - ensuring models on pod') |
| 334 | threading.Thread(target=self._safe_ensure, args=(catalog['active'],), daemon=True).start() | 411 | threading.Thread(target=self._safe_ensure, args=(catalog['active'],), daemon=True).start() |
| 335 | return self._reply(200, status_body()) | 412 | return self._reply(200, status_body()) |
| 336 | except Exception as err: | 413 | except Exception as err: |
| @@ -395,7 +472,7 @@ def main(): | |||
| 395 | log('adoption wait failed:', err) | 472 | log('adoption wait failed:', err) |
| 396 | threading.Thread(target=_adopt, daemon=True).start() | 473 | threading.Thread(target=_adopt, daemon=True).start() |
| 397 | server = ThreadingHTTPServer(('0.0.0.0', LISTEN_PORT), Proxy) | 474 | server = ThreadingHTTPServer(('0.0.0.0', LISTEN_PORT), Proxy) |
| 398 | log(f'runpod-lazy v4 (catalog+lora) on :{LISTEN_PORT} (pod "{POD_NAME}", DCs {DATACENTERS or "any"}, idle {IDLE_SECONDS}s)') | 475 | log(f'runpod-lazy v5 (in-place model switching) on :{LISTEN_PORT} (pod "{POD_NAME}", DCs {DATACENTERS or "any"}, idle {IDLE_SECONDS}s)') |
| 399 | server.serve_forever() | 476 | server.serve_forever() |
| 400 | 477 | ||
| 401 | 478 | ||
| @@ -1580,7 +1580,7 @@ function renderRunpodStatus(status) { | |||
| 1580 | const left = status.idle_seconds_left ? ` (idle stop in ${Math.round(status.idle_seconds_left / 60)}m)` : ''; | 1580 | const left = status.idle_seconds_left ? ` (idle stop in ${Math.round(status.idle_seconds_left / 60)}m)` : ''; |
| 1581 | text.textContent = `ready on ${status.gpu ?? 'GPU'}${left}`; | 1581 | text.textContent = `ready on ${status.gpu ?? 'GPU'}${left}`; |
| 1582 | } else if (phase === 'orange') { | 1582 | } else if (phase === 'orange') { |
| 1583 | text.textContent = `starting (models: ${status.models ?? '?'})…`; | 1583 | text.textContent = `starting (models: ${status.model || (status.active ?? []).join(' + ') || '?'})…`; |
| 1584 | } else { | 1584 | } else { |
| 1585 | text.textContent = 'off'; | 1585 | text.textContent = 'off'; |
| 1586 | } | 1586 | } |
| @@ -1610,20 +1610,31 @@ function ensureRunpodBarDot() { | |||
| 1610 | dot.style.display = getRunpodLazyUrl() ? '' : 'none'; | 1610 | dot.style.display = getRunpodLazyUrl() ? '' : 'none'; |
| 1611 | } | 1611 | } |
| 1612 | 1612 | ||
| 1613 | let runpodPollFailures = 0; | ||
| 1614 | |||
| 1613 | async function pollRunpodStatus() { | 1615 | async function pollRunpodStatus() { |
| 1614 | const url = getRunpodLazyUrl(); | 1616 | const url = getRunpodLazyUrl(); |
| 1615 | if (!url) { | 1617 | if (!url) { |
| 1616 | return; | 1618 | return; |
| 1617 | } | 1619 | } |
| 1618 | let phase = 'red'; | 1620 | let phase = runpodLastPhase; |
| 1619 | try { | 1621 | try { |
| 1620 | const result = await fetch(`${url}/lazy/status`, { signal: AbortSignal.timeout(5000) }); | 1622 | const result = await fetch(`${url}/lazy/status`, { signal: AbortSignal.timeout(8000) }); |
| 1621 | phase = renderRunpodStatus(result.ok ? await result.json() : null); | 1623 | if (!result.ok) { |
| 1624 | throw new Error(`status ${result.status}`); | ||
| 1625 | } | ||
| 1626 | runpodPollFailures = 0; | ||
| 1627 | phase = renderRunpodStatus(await result.json()); | ||
| 1622 | } catch { | 1628 | } catch { |
| 1623 | renderRunpodStatus(null); | 1629 | // A single slow/failed poll (e.g. while the proxy is provisioning a pod) |
| 1630 | // must not flip the dot to red; only sustained unreachability does. | ||
| 1631 | runpodPollFailures++; | ||
| 1632 | if (runpodPollFailures >= 3) { | ||
| 1633 | phase = renderRunpodStatus(null); | ||
| 1634 | } | ||
| 1624 | } | 1635 | } |
| 1625 | clearTimeout(runpodStatusTimer); | 1636 | clearTimeout(runpodStatusTimer); |
| 1626 | runpodStatusTimer = setTimeout(pollRunpodStatus, phase === 'orange' ? RUNPOD_POLL_BUSY_MS : RUNPOD_POLL_IDLE_MS); | 1637 | runpodStatusTimer = setTimeout(pollRunpodStatus, phase === 'orange' || runpodPollFailures ? RUNPOD_POLL_BUSY_MS : RUNPOD_POLL_IDLE_MS); |
| 1627 | } | 1638 | } |
| 1628 | 1639 | ||
| 1629 | async function runpodControl(action) { | 1640 | async function runpodControl(action) { |