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 | 5 | Models come from a CATALOG pushed by the ST UI: entries of |
| 6 | 6 | {"name": display, "value": model filename, "files": [{"dest": "unet/x.gguf", "url": "..."}]} |
| 7 | 7 | The pod downloads exactly the selected entry's files at boot (boot-models.py |
| 8 | 8 | reads MODEL_MANIFEST). Changing the active model with a runningRUNNING pod recreatesdownloads |
| 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 | 13 | Control API (CORS-enabled; ST warmup UI + runpod-pod.sh CLI): |
| 12 | 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 | 48 | state = { |
| 47 | 49 | 'pod_id': None, |
| 48 | 50 | 'model': None, # catalogvalues_key `value`of the podmodels wasthe provisionedpod forholds |
| 49 | 51 | 'phase': 'red', # red | orange | green |
| 50 | 52 | 'gpu': None, |
| 51 | 53 | 'since': time.time(), |
| 52 | 54 | 'last': time.monotonic(), |
| 55 | + 'ensuring': 0, # active ensure_pod waiters (status skips probing then) | |
| 53 | 56 | 'lock': threading.Lock(), |
| 54 | 57 | } |
| 55 | 58 | |
| @@ -91,6 +94,19 @@ def values_key(values): | ||
| 91 | 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 | 110 | def api(method, path, body=None): |
| 95 | 111 | req = urllib.request.Request( |
| 96 | 112 | f'https://rest.runpod.io/v1{path}', |
| @@ -131,9 +147,10 @@ def create_pod(values): | ||
| 131 | 147 | 'gpuCount': 1, |
| 132 | 148 | 'cloudType': CLOUD_TYPE, |
| 133 | 149 | 'containerDiskInGb': 80, |
| 134 | 150 | 'ports': ['8188/http', '8189/http'], |
| 135 | 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 | 155 | if DATACENTERS: |
| 139 | 156 | body['dataCenterIds'] = DATACENTERS |
| @@ -146,6 +163,17 @@ def pod_url(pod_id): | ||
| 146 | 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 | 177 | UA = 'Mozilla/5.0 (compatible; runpod-lazy-proxy)' |
| 150 | 178 | |
| 151 | 179 | |
| @@ -171,36 +199,38 @@ def terminate(pod_id): | ||
| 171 | 199 | log('terminate failed:', err) |
| 172 | 200 | |
| 173 | 201 | |
| 202 | +def _create_pod_with_retries(values): | |
| 203 | + state['phase'] = 'orange' | |
| 204 | + state['since'] = time.time() | |
| 205 | + last_err = None | |
| 206 | + for attempt in range(3): | |
| 207 | + try: | |
| 208 | + return create_pod(values) | |
| 209 | + except urllib.error.HTTPError as err: | |
| 210 | + last_err = err.read().decode()[:200] | |
| 211 | + log(f'create_pod attempt {attempt + 1} failed:', last_err) | |
| 212 | + time.sleep(5) | |
| 213 | + state['phase'] = 'red' | |
| 214 | + raise RuntimeError(f'could not create pod: {last_err}') | |
| 215 | + | |
| 216 | + | |
| 174 | 217 | def ensure_pod(values=None, wait=True): |
| 175 | 218 | """Ensures a pod provisionedholding forthe 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.""" | |
| 176 | 222 | values = values or active_values() |
| 177 | 223 | key = values_key(values) |
| 224 | + files = needed_files(values) | |
| 225 | + dests = [f['dest'].lstrip('/') for f in files] | |
| 226 | + created = False | |
| 178 | 227 | with state['lock']: |
| 179 | 228 | pod_id, have, gpu = (state['pod_id'], state['model'], state['gpu']) |
| 180 | 229 | if not pod_id: |
| 181 | 230 | 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 | 231 | if not pod_id: |
| 189 | - state['phase'] = 'orange' | |
| 232 | + pod_id, gpu = _create_pod_with_retries(values) | |
| 190 | - state['since'] = time.time() | |
| 233 | + have, created = key, True | |
| 191 | - last_err = None | |
| 192 | - for attempt in range(3): | |
| 193 | - try: | |
| 194 | - pod_id, gpu = create_pod(values) | |
| 195 | - have = key | |
| 196 | - break | |
| 197 | - except urllib.error.HTTPError as err: | |
| 198 | - last_err = err.read().decode()[:200] | |
| 199 | - log(f'create_pod attempt {attempt + 1} failed:', last_err) | |
| 200 | - time.sleep(5) | |
| 201 | - if not pod_id: | |
| 202 | - state['phase'] = 'red' | |
| 203 | - raise RuntimeError(f'could not create pod: {last_err}') | |
| 204 | 234 | state['pod_id'], state['model'], state['gpu'] = pod_id, have, gpu |
| 205 | 235 | state['last'] = time.monotonic() |
| 206 | 236 | |
| @@ -208,21 +238,63 @@ def ensure_pod(values=None, wait=True): | ||
| 208 | 238 | state['phase'] = 'orange' |
| 209 | 239 | if not wait: |
| 210 | 240 | return pod_id |
| 211 | - deadline = time.monotonic() + START_TIMEOUT | |
| 241 | + # Freshly created pods boot with the right manifest; existing pods need an | |
| 212 | - while time.monotonic() < deadline: | |
| 242 | + # /ensure pushed once the manager answers. | |
| 213 | - # Booting/downloading counts as activity, else the reaper would kill a | |
| 243 | + ensured = created or not files | |
| 214 | - # warming pod mid-download. | |
| 244 | + no_manager_strikes = 0 | |
| 215 | 245 | state['lastensuring'] += time.monotonic()1 |
| 216 | - if upstream_ready(pod_id): | |
| 246 | + try: | |
| 217 | - if state['phase'] != 'green': | |
| 247 | + deadline = time.monotonic() + START_TIMEOUT | |
| 218 | - state['phase'] = 'green' | |
| 248 | + while time.monotonic() < deadline: | |
| 219 | - state['since'] = time.time() | |
| 249 | + # Booting/downloading counts as activity, else the reaper would | |
| 220 | - log('pod ready:', pod_id) | |
| 250 | + # kill a warming pod mid-download. | |
| 221 | - return pod_id | |
| 251 | + state['last'] = time.monotonic() | |
| 222 | - if state['pod_id'] != pod_id: | |
| 252 | + if not ensured: | |
| 223 | - raise RuntimeError('pod replaced while waiting') | |
| 253 | + try: | |
| 224 | - time.sleep(5) | |
| 254 | + mm_request(pod_id, 'POST', '/ensure', {'files': files}) | |
| 225 | - raise RuntimeError('pod not ready within START_TIMEOUT') | |
| 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: | |
| 285 | + if state['phase'] != 'green': | |
| 286 | + state['phase'] = 'green' | |
| 287 | + state['since'] = time.time() | |
| 288 | + log('pod ready:', pod_id) | |
| 289 | + return pod_id | |
| 290 | + if state['phase'] == 'green': | |
| 291 | + state['phase'] = 'orange' # in-place download in progress | |
| 292 | + if state['pod_id'] != pod_id: | |
| 293 | + raise RuntimeError('pod replaced while waiting') | |
| 294 | + time.sleep(5) | |
| 295 | + raise RuntimeError('pod not ready within START_TIMEOUT') | |
| 296 | + finally: | |
| 297 | + state['ensuring'] -= 1 | |
| 226 | 298 | |
| 227 | 299 | |
| 228 | 300 | def match_workflow_models(workflow_bytes): |
| @@ -254,10 +326,15 @@ def idle_reaper(): | ||
| 254 | 326 | |
| 255 | 327 | def status_body(): |
| 256 | 328 | pod_id = state['pod_id'] |
| 257 | 329 | if pod_id and state['phaseensuring'] != 'green' and> upstream_ready(pod_id)0: |
| 258 | - state['phase'] = 'green' | |
| 330 | + # An ensure_pod waiter owns the phase; probing here could report green | |
| 259 | - state['since'] = time.time() | |
| 331 | + # mid-download or block the status reply on a dead pod's proxy URL. | |
| 260 | - if not pod_id: | |
| 332 | + pass | |
| 333 | + elif pod_id: | |
| 334 | + if state['phase'] != 'green' and upstream_ready(pod_id): | |
| 335 | + state['phase'] = 'green' | |
| 336 | + state['since'] = time.time() | |
| 337 | + else: | |
| 261 | 338 | found, have, gpu = find_pod() |
| 262 | 339 | if found: |
| 263 | 340 | state.update({'pod_id': found, 'model': have, 'gpu': gpu}) |
| @@ -327,10 +404,10 @@ class Proxy(BaseHTTPRequestHandler): | ||
| 327 | 404 | catalog = {'models': payload.get('models', []), 'active': [a for a in (active or []) if a]} |
| 328 | 405 | save_catalog(catalog) |
| 329 | 406 | log(f"catalog updated: {len(catalog['models'])} models, active={catalog['active']}") |
| 330 | 407 | # Re-provisionFetch missing models proactively when the active modelset changed under a live pod |
| 331 | 408 | # (known-modelunder podsa onlylive -pod never(in churnplace; arecreate podonly offor unknownold provenanceimages). |
| 332 | 409 | if state['pod_id'] and catalog['active'] and state['model'] and statenot (set(catalog['modelactive']) !<= values_keykey_values(catalogstate['activemodel'])): |
| 333 | 410 | log('active model set changed - re-provisioningensuring models on pod') |
| 334 | 411 | threading.Thread(target=self._safe_ensure, args=(catalog['active'],), daemon=True).start() |
| 335 | 412 | return self._reply(200, status_body()) |
| 336 | 413 | except Exception as err: |
| @@ -395,7 +472,7 @@ def main(): | ||
| 395 | 472 | log('adoption wait failed:', err) |
| 396 | 473 | threading.Thread(target=_adopt, daemon=True).start() |
| 397 | 474 | server = ThreadingHTTPServer(('0.0.0.0', LISTEN_PORT), Proxy) |
| 398 | 475 | log(f'runpod-lazy v4v5 (catalog+lorain-place model switching) on :{LISTEN_PORT} (pod "{POD_NAME}", DCs {DATACENTERS or "any"}, idle {IDLE_SECONDS}s)') |
| 399 | 476 | server.serve_forever() |
| 400 | 477 | |
| 401 | 478 | |
| @@ -1580,7 +1580,7 @@ function renderRunpodStatus(status) { | ||
| 1580 | 1580 | const left = status.idle_seconds_left ? ` (idle stop in ${Math.round(status.idle_seconds_left / 60)}m)` : ''; |
| 1581 | 1581 | text.textContent = `ready on ${status.gpu ?? 'GPU'}${left}`; |
| 1582 | 1582 | } else if (phase === 'orange') { |
| 1583 | 1583 | text.textContent = `starting (models: ${status.modelsmodel || (status.active ?? []).join(' + ') || '?'})…`; |
| 1584 | 1584 | } else { |
| 1585 | 1585 | text.textContent = 'off'; |
| 1586 | 1586 | } |
| @@ -1610,20 +1610,31 @@ function ensureRunpodBarDot() { | ||
| 1610 | 1610 | dot.style.display = getRunpodLazyUrl() ? '' : 'none'; |
| 1611 | 1611 | } |
| 1612 | 1612 | |
| 1613 | +let runpodPollFailures = 0; | |
| 1614 | + | |
| 1613 | 1615 | async function pollRunpodStatus() { |
| 1614 | 1616 | const url = getRunpodLazyUrl(); |
| 1615 | 1617 | if (!url) { |
| 1616 | 1618 | return; |
| 1617 | 1619 | } |
| 1618 | 1620 | let phase = 'red'runpodLastPhase; |
| 1619 | 1621 | try { |
| 1620 | 1622 | const result = await fetch(`${url}/lazy/status`, { signal: AbortSignal.timeout(50008000) }); |
| 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 | 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 | 1636 | clearTimeout(runpodStatusTimer); |
| 1626 | 1637 | runpodStatusTimer = setTimeout(pollRunpodStatus, phase === 'orange' || runpodPollFailures ? RUNPOD_POLL_BUSY_MS : RUNPOD_POLL_IDLE_MS); |
| 1627 | 1638 | } |
| 1628 | 1639 | |
| 1629 | 1640 | async function runpodControl(action) { |