Stable Diffusion: tolerate transient status-poll failures; runpod-lazy v5 in-place model switching (homelab)

4a554f33b396544160b7a277cce2ded01b987627

permissionBRICK <40219477+permissionBRICK@users.noreply.github.com>

2 files changed, +128 -40Showing whitespace changes
homelab/runpod-lazy-proxy.py+111 -34
@@ -5,8 +5,10 @@ SillyTavern's fallback chain points at this proxy as a normal ComfyUI server.
5Models come from a CATALOG pushed by the ST UI: entries of5Models 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": "..."}]}
7The pod downloads exactly the selected entry's files at boot (boot-models.py7The pod downloads exactly the selected entry's files at boot (boot-models.py
8reads MODEL_MANIFEST). Changing the active model with a running pod recreates8reads MODEL_MANIFEST). Changing the active model with a RUNNING pod downloads
9it (fresh disk = old model gone, only the new one downloads).9the new files IN PLACE through the pod's model-manager (port 8189; LRU-evicts
10old models if the disk runs short) - the pod is only recreated when it runs an
11old image without the manager.
1012
11Control API (CORS-enabled; ST warmup UI + runpod-pod.sh CLI):13Control 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')
4547
46state = {48state = {
47 'pod_id': None,49 'pod_id': None,
48 'model': None, # catalog `value` the pod was provisioned for50 'model': None, # values_key of the models the pod holds
49 'phase': 'red', # red | orange | green51 '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}
5558
@@ -91,6 +94,19 @@ def values_key(values):
91 return '+'.join(sorted(values)) if values else ''94 return '+'.join(sorted(values)) if values else ''
9295
9396
97def key_values(key):
98 return set(k for k in (key or '').split('+') if k)
99
100
101def 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
94def api(method, path, body=None):110def 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'] = DATACENTERS156 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'
147164
148165
166def 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
149UA = 'Mozilla/5.0 (compatible; runpod-lazy-proxy)'177UA = 'Mozilla/5.0 (compatible; runpod-lazy-proxy)'
150178
151179
@@ -171,36 +199,38 @@ def terminate(pod_id):
171 log('terminate failed:', err)199 log('terminate failed:', err)
172200
173201
174def ensure_pod(values=None, wait=True):202def _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 = None205 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
217def 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, gpu234 state['pod_id'], state['model'], state['gpu'] = pod_id, have, gpu
205 state['last'] = time.monotonic()235 state['last'] = time.monotonic()
206236
@@ -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_id240 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_TIMEOUT247 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 a249 # 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_id289 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
226298
227299
228def match_workflow_models(workflow_bytes):300def match_workflow_models(workflow_bytes):
@@ -254,10 +326,15 @@ def idle_reaper():
254326
255def status_body():327def 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 pod407 # 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()
400477
401478
public/scripts/extensions/stable-diffusion/index.js+17 -6
@@ -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}
16121612
1613let runpodPollFailures = 0;
1614
1613async function pollRunpodStatus() {1615async 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}
16281639
1629async function runpodControl(action) {1640async function runpodControl(action) {