studio: crash-durable SQLite render queue + engine selector
SqlitePromptQueue (middleware/tasks_queue.py): drop-in for PromptQueue backed
by one SQLite file (stdlib sqlite3, WAL, zero external processes). Opt in with
STUDIO_QUEUE_DB; precedence STUDIO_QUEUE_DB > STUDIO_PERSIST_QUEUE > memory so
local default is unchanged. The put/get/task_done seam maps to a durable job
state machine: idempotent submit (INSERT OR IGNORE on prompt_id), exactly-once
claim (BEGIN IMMEDIATE), retry-on-error, and crash-recovery via a lease + reap
that re-runs an abandoned claim (idempotent — SaveImage suffixes increment).
Multiple Studio processes on the same DB compete safely.
Engine selector (middleware/engine_selector.py): GET /v1/engines lists execution
targets for the org (local + registered compute_config workers), PUT
/v1/engines/default sets a per-org default stored on ComputeProfile; route_prompt
honors it (local = unchanged). Leased cloud machines are a future engine class
(interface stubbed). Frontend picker is a TODO.
docs/federation.md rewritten to the durable-queue reality with one future
paragraph (remote Tasks backend + Go/unified-binary HIP-0106 migration + leased
machines). Tests: middleware_test/{tasks_queue,engine_selector}_test.py, incl.
crash-recovery across queue instances.
This commit is contained in:
@@ -60,9 +60,29 @@ shared Hanzo IAM (standard OIDC code flow). No embedding.
|
||||
the prompt worker from the queue item's `org_id`.
|
||||
- **GPU federation** (`middleware/{worker_client,prompt_router,compute_config,
|
||||
visor_client}.py`): `/v1/workers/register` (heartbeat), `/v1/workers` (list),
|
||||
`/v1/worker/execute` (push). Worker↔coordinator trust via
|
||||
`X-Worker-Token` (`STUDIO_WORKER_TOKEN`, KMS-sourced). NAT'd local boxes (GB10)
|
||||
join outbound — pull channel is specced in `docs/federation.md`.
|
||||
`/v1/worker/execute` (in-cluster push fast path). Worker↔coordinator trust via
|
||||
`X-Worker-Token` (`STUDIO_WORKER_TOKEN`, KMS-sourced).
|
||||
- **Durable render queue** (`middleware/tasks_queue.py`, `docs/federation.md`):
|
||||
`SqlitePromptQueue` — crash-durable render queue in a single SQLite file
|
||||
(stdlib `sqlite3`, WAL, **zero external processes**). `STUDIO_QUEUE_DB=<path>`
|
||||
→ `server.py` swaps `PromptQueue` for it (precedence **STUDIO_QUEUE_DB >
|
||||
STUDIO_PERSIST_QUEUE > memory**; default local behavior unchanged). The
|
||||
`PromptQueue` seam maps to a durable job state machine: `put→INSERT (pending,
|
||||
idempotent on prompt_id)`, `get→claim next pending under BEGIN IMMEDIATE
|
||||
(exactly-once, leased)`, `task_done→done | retry/failed`. Crash-recovery: a
|
||||
lease + reap returns an abandoned claim to `pending` (on every get(), a
|
||||
heartbeat thread, and on boot) so another process re-runs it — idempotent
|
||||
(SaveImage suffixes increment). Multiple Studio processes on the same DB file
|
||||
compete safely. Tested: `middleware_test/tasks_queue_test.py` (incl.
|
||||
crash-recovery across instances). Future (federation.md §6): a remote queue
|
||||
backend served by Hanzo Tasks + a Go/unified-binary (HIP-0106) migration.
|
||||
- **Engine selector** (`middleware/engine_selector.py`): `/v1/engines` lists
|
||||
execution targets for the org (`local` + registered compute_config workers) and
|
||||
`PUT /v1/engines/default` sets a per-org default (stored on
|
||||
`ComputeProfile.default_engine`). `prompt_router.route_prompt` routes to the
|
||||
chosen worker; `local` = unchanged. Leased cloud machines (Visor
|
||||
`GET /v1/machines`) are a future engine class (interface stubbed, not built).
|
||||
Frontend picker is a TODO. Tested: `middleware_test/engine_selector_test.py`.
|
||||
|
||||
## Tests
|
||||
`tests-unit/{folder_paths_test,middleware_test,app_test,prompt_server_test}` —
|
||||
|
||||
+117
-130
@@ -1,168 +1,155 @@
|
||||
# Hanzo Studio — GPU Federation
|
||||
|
||||
How `studio.hanzo.ai` schedules generation jobs across a heterogeneous GPU pool:
|
||||
in-cluster cloud pods **and** local boxes (e.g. a GB10) that join over an
|
||||
**outbound-only** connection. No inbound tunnel to the local box is ever
|
||||
required.
|
||||
How `studio.hanzo.ai` runs generation jobs durably and spreads them across a
|
||||
heterogeneous GPU pool: in-cluster cloud pods **and** local boxes (e.g. a GB10)
|
||||
that join over an **outbound-only** connection. No inbound tunnel to a local box
|
||||
is ever required.
|
||||
|
||||
This document is the contract. Sections are marked **[implemented]** (code in
|
||||
this repo today) or **[specced]** (agreed design, not yet wired). Nothing here is
|
||||
a stub in code — specced pieces live in this doc until they are built.
|
||||
Sections are marked **[implemented]** (code in this repo today), **[fast-path]**
|
||||
(retained in-cluster optimization), or **[future]**.
|
||||
|
||||
## 1. Roles
|
||||
|
||||
- **Coordinator** — the `studio.hanzo.ai` deployment. Full UI, IAM auth,
|
||||
multi-tenant storage. Owns the per-org worker registry and the job schedule.
|
||||
Runs a small CPU footprint (it routes; it does not need a GPU).
|
||||
- **Worker** — a headless Studio started with `--worker-mode`. Reports its
|
||||
device (GPU model, VRAM), registers with the coordinator, and executes prompts
|
||||
it is given. A worker belongs to exactly one org (`STUDIO_ORG_ID`).
|
||||
multi-tenant storage. Accepts `/prompt`, owns the durable render queue and the
|
||||
per-org worker registry + engine selection.
|
||||
- **Worker** — a Studio process that executes prompts. Either the same box as the
|
||||
coordinator (local render), an in-cluster pod reached by push (§2a), or a box
|
||||
sharing the coordinator's render queue.
|
||||
|
||||
A worker is the *same binary* as the coordinator; `--worker-mode` only drops the
|
||||
UI/websocket surface and turns on registration + the execute endpoint. This is
|
||||
the ComfyUI-Distributed lineage (workers are full ComfyUI instances the control
|
||||
plane drives) narrowed to **job-level** dispatch and hardened for multi-tenant +
|
||||
NAT traversal.
|
||||
A worker is the *same binary* as the coordinator.
|
||||
|
||||
## 2. Two dispatch models
|
||||
## 2. Durable render queue **[implemented]**
|
||||
|
||||
Reachability, not preference, decides which model a worker uses.
|
||||
The render queue is crash-durable: pending and in-flight prompts survive a
|
||||
process crash, and a killed render is re-run instead of being lost. Storage is a
|
||||
single **SQLite** file (stdlib `sqlite3`, WAL) — **zero external processes**, per
|
||||
the house rule (SQLite embedded by default; Postgres only for prod multi-instance).
|
||||
|
||||
### 2a. Push — for in-cluster workers **[implemented]**
|
||||
Enable with `STUDIO_QUEUE_DB=<path>`. `server.py` then swaps `PromptQueue` for
|
||||
`SqlitePromptQueue`; the existing prompt-executor thread is the claim loop
|
||||
unchanged. The `PromptQueue` seam maps onto a durable job state machine:
|
||||
|
||||
The coordinator can open a connection to the worker (both are pods in
|
||||
`hanzo-k8s`, or the worker has a routable `WORKER_EXTERNAL_URL`).
|
||||
```
|
||||
put(item) → INSERT job (pending) # idempotent on prompt_id
|
||||
get(timeout) → claim next pending (claimed, leased) # BEGIN IMMEDIATE
|
||||
task_done(success) → job → done
|
||||
task_done(error) → job → pending (retry) | failed # per STUDIO_TASKS_MAX_ATTEMPTS
|
||||
```
|
||||
|
||||
Guarantees:
|
||||
|
||||
- **Exactly-once submit** — the row is keyed by `prompt_id` (`INSERT OR IGNORE`);
|
||||
a resubmit of the same prompt is a no-op.
|
||||
- **Exactly-once claim** — `get()` claims under `BEGIN IMMEDIATE`, so with several
|
||||
Studio processes on the same `STUDIO_QUEUE_DB` exactly one claims each job.
|
||||
- **Crash-recovery** — a claim carries a lease. A background heartbeat renews it
|
||||
while the render runs; if the process dies, the lease expires and the reap step
|
||||
(run on every `get()`, in the heartbeat thread, and on boot) returns the job to
|
||||
`pending` for another claim. A render retry is idempotent — SaveImage suffixes
|
||||
increment.
|
||||
|
||||
Files: `middleware/tasks_queue.py` (`SqlitePromptQueue`), `server.py` (queue
|
||||
selection). Precedence:
|
||||
|
||||
| Precedence | Trigger | Backend | Durability |
|
||||
|---|---|---|---|
|
||||
| 1 | `STUDIO_QUEUE_DB` set | `SqlitePromptQueue` (one file) | Crash-durable, exactly-once, retry, multi-process |
|
||||
| 2 | `STUDIO_PERSIST_QUEUE=1` | `PromptQueue` + JSON snapshot | Single-box crash-survival (re-queue on boot) |
|
||||
| 3 | (default) | `PromptQueue` (in-memory) | None — same as upstream ComfyUI |
|
||||
|
||||
Env: `STUDIO_QUEUE_DB` (path), `STUDIO_TASKS_QUEUE` (default `studio-render`),
|
||||
`STUDIO_TASKS_MAX_ATTEMPTS` (default 3), `STUDIO_TASKS_LEASE_MS` (default 60000,
|
||||
heartbeat renews at lease/3), `STUDIO_WORKER_ID` (default `<host>-<pid>`).
|
||||
|
||||
### 2a. Push — in-cluster fast path **[fast-path]**
|
||||
|
||||
When the coordinator can already reach a worker (both pods in `hanzo-k8s`, or a
|
||||
routable `WORKER_EXTERNAL_URL`), it may forward a prompt directly:
|
||||
|
||||
```
|
||||
client → POST /prompt (coordinator)
|
||||
prompt_router.route_prompt(org, body)
|
||||
→ get_available_gpu_worker(org) # compute_config registry
|
||||
→ POST {worker.url}/v1/worker/execute # forward_to_worker
|
||||
worker queues + executes locally, returns {prompt_id, ...}
|
||||
```
|
||||
|
||||
Files: `middleware/prompt_router.py`, `middleware/worker_client.py`
|
||||
(`add_worker_routes` → `/v1/worker/execute`), `server.py` (`/prompt` calls
|
||||
`route_prompt`).
|
||||
Files: `middleware/prompt_router.py`, `middleware/worker_client.py`. This path is
|
||||
not durable on its own (a forwarded prompt lost to a worker crash is not retried);
|
||||
keep it for latency-sensitive in-cluster dispatch. It requires the coordinator to
|
||||
reach the worker, so it cannot serve a NAT'd box.
|
||||
|
||||
Limitation: requires the coordinator to reach the worker. A GB10 behind NAT is
|
||||
**not** reachable, so push cannot serve it.
|
||||
## 3. Engine selector **[implemented]**
|
||||
|
||||
### 2b. Pull — for NAT'd local boxes (the GB10 pattern) **[specced]**
|
||||
An org chooses which execution target ("engine") its prompts run on. Engines are
|
||||
derived from what already exists in the per-org compute registry:
|
||||
|
||||
The worker dials **out** and keeps the connection; the coordinator pushes jobs
|
||||
down that already-open channel. No inbound port on the box.
|
||||
- `local` — this Studio instance (always present), and
|
||||
- each registered org GPU worker (from `compute.json`'s worker registry).
|
||||
|
||||
Primary transport is a WebSocket the worker opens to the coordinator:
|
||||
Endpoints (coordinator):
|
||||
|
||||
```
|
||||
worker → WSS {coordinator}/v1/workers/connect (Authorization: worker token)
|
||||
← {type:"job", job_id, prompt, extra_data, org_id}
|
||||
→ {type:"progress", job_id, value, max}
|
||||
→ {type:"result", job_id, status, outputs:[{name, s3_url}...]}
|
||||
```
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/v1/engines` | List engines for the org + the current default. |
|
||||
| PUT | `/v1/engines/default` | Set the org's default engine. Body `{"engine": "<id>"}`. |
|
||||
|
||||
HTTP long-poll fallback for environments without WSS:
|
||||
The default is stored on the org's compute profile (`ComputeProfile.default_engine`).
|
||||
`prompt_router.route_prompt` honors it: if the default names a live worker, the
|
||||
prompt is forwarded there; `local` (the default) leaves current behavior
|
||||
unchanged. A stale default (worker deregistered) falls back to `local`, never 500.
|
||||
|
||||
```
|
||||
worker → POST /v1/jobs/claim {worker_id, org_id} # long-poll, ≤30s
|
||||
← 204 (no work) | 200 {job_id, prompt, extra_data}
|
||||
worker → POST /v1/jobs/{job_id}/result {status, outputs}
|
||||
```
|
||||
Files: `middleware/engine_selector.py`, `middleware/compute_config.py`,
|
||||
`middleware/prompt_router.py`. **Frontend TODO:** a queue-panel/settings dropdown
|
||||
to pick the engine — the API + per-org default ship now; the UI control is a
|
||||
follow-up.
|
||||
|
||||
The coordinator keeps a per-org, per-worker job queue (a natural extension of
|
||||
`compute_config`), enqueues on `/prompt` when the target worker is pull-mode, and
|
||||
hands the job to whichever channel the worker holds open. `route_prompt` gains a
|
||||
third branch (`{"action":"queued"}` → `202`) alongside the existing
|
||||
forward/provisioning/unavailable branches.
|
||||
## 4. Security **[implemented]**
|
||||
|
||||
Not implemented because it does not "drop out" of ComfyUI's `--listen`
|
||||
architecture — it needs a real coordinator-side queue and a WS control channel.
|
||||
Building it does not change the endpoints below.
|
||||
- **User auth** — all user-facing routes require an IAM JWT (JWKS-verified); org
|
||||
comes from the `owner` claim. See `middleware/iam_auth_middleware.py`.
|
||||
- **Worker trust** — the IAM-exempt `/v1/worker/execute` and `/v1/workers/register`
|
||||
require `X-Worker-Token == STUDIO_WORKER_TOKEN` (`worker_client.verify_worker_token`).
|
||||
KMS-sourced in cloud; empty in a single-trust-domain local dev, where the check
|
||||
is skipped.
|
||||
- **Org isolation** — a render carries its `org_id` in `extra_data`; the worker
|
||||
binds it as the execution org so outputs land in that org's namespace (§5). A
|
||||
worker only serves its own `STUDIO_ORG_ID`.
|
||||
|
||||
## 3. Endpoints
|
||||
## 5. Output persistence **[future]**
|
||||
|
||||
All under `/v1/` (also served under the `/api/v1/` alias ComfyUI adds for the
|
||||
frontend proxy). Worker↔coordinator endpoints are exempt from user IAM and
|
||||
carry the coordinator shared secret instead (`X-Worker-Token`, §5).
|
||||
Workers execute against their own output dir (org-scoped via
|
||||
`folder_paths.set_execution_org`). For the coordinator UI to serve results across
|
||||
boxes, workers upload outputs to `s3.lux.cloud` (hanzo bucket), key
|
||||
`studio/{org_id}/{prompt_id}/{filename}`, and the coordinator serves via `/view`
|
||||
(or a signed redirect). Until then, results remain on the executing worker —
|
||||
fine for the single-box and in-cluster cases.
|
||||
|
||||
| Method | Path | Dir | Status | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/v1/workers/register` | worker→coord | **impl** | Register / heartbeat. Body: `{worker_id, url, org_id, device, gpu_model, vram_gb, status}`. |
|
||||
| GET | `/v1/workers` | client→coord | **impl** | List the org's workers + liveness. |
|
||||
| POST | `/v1/worker/execute` | coord→worker | **impl** | Push a prompt to a reachable worker. Same body as `/prompt`. |
|
||||
| WSS | `/v1/workers/connect` | worker→coord | **spec** | Persistent pull channel for NAT'd workers. |
|
||||
| POST | `/v1/jobs/claim` | worker→coord | **spec** | HTTP long-poll fallback to claim a job. |
|
||||
| POST | `/v1/jobs/{id}/result` | worker→coord | **spec** | Report result + artifact URLs. |
|
||||
| GET | `/v1/compute/config` | client→coord | **impl** | Read the org's compute profile. |
|
||||
| PUT | `/v1/compute/config` | client→coord | **impl** | Update profile (GPU tier, auto-provision). |
|
||||
| POST | `/v1/compute/provision` | client→coord | **impl** | Autoscale a cloud GPU worker via Visor. |
|
||||
## 6. Future direction
|
||||
|
||||
(Compute-profile + Visor autoscale routes are still under `/compute/*` in
|
||||
`server.py`; the `/v1/compute/*` labels above are the canonical names to fold to
|
||||
when those handlers are next touched. Worker-registry + execute already moved.)
|
||||
|
||||
## 4. Registration & scheduling **[implemented]**
|
||||
|
||||
- Worker sends a heartbeat every 30s (`WorkerClient._heartbeat_loop`).
|
||||
- Coordinator upserts it into the org's `compute.json`
|
||||
(`compute_config.register_worker`), pruning workers silent >300s.
|
||||
Liveness for scheduling is 90s (`WorkerInfo.is_alive`).
|
||||
- `get_available_gpu_worker(org)` returns the first ready, alive CUDA worker.
|
||||
- `prompt_router.route_prompt` chooses local vs worker from the prompt's
|
||||
`device_preference` (`auto`|`cpu`|`gpu`) and the org's `gpu_enabled`.
|
||||
- If no worker and the org has `auto_provision`, Visor launches a cloud GPU VM
|
||||
that boots a worker (`middleware/visor_client.py`).
|
||||
|
||||
## 5. Security
|
||||
|
||||
- **User auth** — all user-facing routes require an IAM JWT (JWKS-verified);
|
||||
org comes from the `owner` claim. See `middleware/iam_auth_middleware.py`.
|
||||
- **Worker trust** — worker↔coordinator endpoints are IAM-exempt (they carry no
|
||||
user) and instead require `X-Worker-Token == STUDIO_WORKER_TOKEN`
|
||||
(`worker_client.verify_worker_token`). The token is KMS-sourced in cloud;
|
||||
empty in a single-trust-domain local dev, where the check is skipped.
|
||||
**[specced]** forward direction: replace the shared secret with an IAM
|
||||
service-account token (`hanzo-studio` client-credentials grant) so each worker
|
||||
is individually attributable and revocable.
|
||||
- **Org isolation** — a job carries its `org_id`; the worker binds it as the
|
||||
execution org so outputs land in that org's namespace (§6). A worker only ever
|
||||
claims jobs for its own `STUDIO_ORG_ID`.
|
||||
- **No inbound** — pull-mode boxes expose nothing; the coordinator is reached
|
||||
over TLS via `hanzoai/ingress`.
|
||||
|
||||
## 6. Output persistence **[specced]**
|
||||
|
||||
Push/pull both execute on the worker, which writes to *its own* output dir
|
||||
(org-scoped via `folder_paths.set_execution_org`). For the coordinator UI to
|
||||
show results, workers must ship artifacts to shared storage:
|
||||
|
||||
- On completion, upload outputs to `s3.lux.cloud` (hanzo bucket),
|
||||
key `studio/{org_id}/{prompt_id}/{filename}`.
|
||||
- Report the object URLs in the `result` message; the coordinator records them
|
||||
in history so `/view` (or a signed redirect) serves them.
|
||||
|
||||
Until this lands, push-mode results remain on the worker and are addressable
|
||||
only if the worker is reachable — acceptable for the in-cluster pool, blocking
|
||||
for the NAT pull pool. This is the top build item after the pull channel.
|
||||
The durable queue, engine selection, and dispatch are deliberately kept in a thin
|
||||
Python layer at the `PromptQueue`/`prompt_router` seams so the backend can be
|
||||
swapped without touching the render path. Two moves are planned: (a) a **remote
|
||||
queue backend** — the same job state machine served by **Hanzo Tasks**
|
||||
(`github.com/hanzoai/tasks`) over its HTTP surface, so multiple coordinators and
|
||||
NAT'd `--worker`-style claimers share one durable queue instead of a local file;
|
||||
and (b) folding that scheduling/queue layer into a **Go subsystem inside the
|
||||
`hanzoai/cloud` unified binary** (HIP-0106), leaving Studio's Python as a pure
|
||||
execution worker. The engine selector will likewise grow a third class,
|
||||
**leased cloud machines** provisioned via the platform's Visor compute surface
|
||||
(`GET {cloud}/v1/machines`) — the `/v1/engines` shape already accommodates the
|
||||
extra entries; the client interface is stubbed in `engine_selector.py` and wired
|
||||
when the console lands.
|
||||
|
||||
## 7. Joining a local box (GB10)
|
||||
|
||||
```bash
|
||||
python main.py \
|
||||
--worker-mode \
|
||||
--coordinator-url https://studio.hanzo.ai \
|
||||
--worker-id gb10-1
|
||||
# env: STUDIO_ORG_ID=<org> STUDIO_WORKER_TOKEN=<from KMS>
|
||||
```
|
||||
Two paths today:
|
||||
|
||||
Today (push) this works only if the coordinator can reach the box (set
|
||||
`WORKER_EXTERNAL_URL`). Once §2b lands, the box needs **no** inbound access: it
|
||||
registers, opens the WSS channel, and pulls jobs for its org.
|
||||
- **Shared queue** (durable): point the box at the same `STUDIO_QUEUE_DB` (shared
|
||||
volume) as the coordinator; its prompt-executor thread claims jobs directly.
|
||||
Exactly-once claim + crash-recovery hold across processes.
|
||||
- **Push** (in-cluster, fast): `python main.py --worker-mode --coordinator-url … --worker-id gb10-1`
|
||||
with `STUDIO_WORKER_TOKEN`; requires the coordinator to reach the box.
|
||||
|
||||
## 8. Build order
|
||||
|
||||
1. Pull WSS channel `/v1/workers/connect` + coordinator per-worker queue (§2b).
|
||||
2. Artifact upload to `s3.lux.cloud` + result recording (§6).
|
||||
3. IAM service-account worker identity replacing the shared secret (§5).
|
||||
4. Fold `/compute/*` profile routes to `/v1/compute/*` (§3).
|
||||
The remote-queue backend (§6) generalizes the shared-queue path to boxes that
|
||||
cannot share a filesystem, outbound-only.
|
||||
|
||||
@@ -63,6 +63,7 @@ class ComputeProfile:
|
||||
workers: list = field(default_factory=list)
|
||||
auto_provision: bool = False
|
||||
max_concurrent: int = 1
|
||||
default_engine: str = "local" # engine id renders route to (see engine_selector)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
@@ -139,6 +140,9 @@ def update_config(org_id: str, updates: dict) -> ComputeProfile:
|
||||
mc = int(updates["max_concurrent"])
|
||||
profile.max_concurrent = max(1, min(mc, 32))
|
||||
|
||||
if "default_engine" in updates:
|
||||
profile.default_engine = str(updates["default_engine"])
|
||||
|
||||
# Derive gpu_enabled from profile if not explicitly set
|
||||
if "active_profile" in updates and "gpu_enabled" not in updates:
|
||||
profile.gpu_enabled = profile.active_profile in ("gpu-basic", "gpu-pro", "custom")
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Engine selector for Hanzo Studio.
|
||||
|
||||
Lists the execution targets ("engines") available to an org and records the
|
||||
org's default. Thin wrapper over the existing per-org compute registry
|
||||
(middleware/compute_config.py): an engine is either
|
||||
|
||||
- `local` — this Studio instance, always present, and
|
||||
- a registered org GPU worker (from compute.json's worker registry).
|
||||
|
||||
Selecting an engine routes prompts to it; prompt_router.py is the existing
|
||||
routing seam and already forwards to a worker by URL, so the default engine
|
||||
just names which target a prompt without an explicit device_preference lands on.
|
||||
|
||||
Leased cloud machines (via the platform's Visor compute surface,
|
||||
`GET {cloud}/v1/machines`) are a future engine class — the client interface is
|
||||
noted below but not built this iteration; the API shape here already
|
||||
accommodates the extra entries.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from middleware import compute_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Future: leased-machine engines. A VisorMachinesClient would implement
|
||||
# list_machines(org_id) -> list[dict] hitting GET {STUDIO_CLOUD_API_URL}/v1/machines
|
||||
# and its rows would be appended to list_engines() as {"type": "leased", ...}.
|
||||
# Console wiring + the client land in a later iteration; not built now.
|
||||
|
||||
|
||||
def list_engines(org_id: str) -> dict:
|
||||
"""Available engines for org_id plus the current default."""
|
||||
profile = compute_config.load_config(org_id)
|
||||
engines = [{
|
||||
"id": "local",
|
||||
"type": "local",
|
||||
"name": "This instance",
|
||||
"status": "ready",
|
||||
"device": "local",
|
||||
}]
|
||||
for w in profile.workers:
|
||||
engines.append({
|
||||
"id": w.worker_id,
|
||||
"type": "worker",
|
||||
"name": w.gpu_model or w.worker_id,
|
||||
"status": "ready" if (w.status == "ready" and w.is_alive()) else "offline",
|
||||
"device": w.device,
|
||||
"vram_gb": w.vram_gb,
|
||||
"url": w.url,
|
||||
})
|
||||
default = profile.default_engine or "local"
|
||||
if default != "local" and not any(e["id"] == default for e in engines):
|
||||
# The default engine deregistered — fall back to local, don't 500.
|
||||
default = "local"
|
||||
return {"engines": engines, "default": default}
|
||||
|
||||
|
||||
def resolve_engine_worker(org_id: str):
|
||||
"""If the org's default engine is a live worker, return it; else None (local)."""
|
||||
profile = compute_config.load_config(org_id)
|
||||
default = profile.default_engine or "local"
|
||||
if default == "local":
|
||||
return None
|
||||
for w in profile.workers:
|
||||
if w.worker_id == default and w.is_alive():
|
||||
return w
|
||||
return None
|
||||
|
||||
|
||||
def add_engine_routes(routes: web.RouteTableDef, server):
|
||||
"""Register /v1/engines on the coordinator."""
|
||||
|
||||
@routes.get("/v1/engines")
|
||||
async def get_engines(request):
|
||||
org_id = server._get_org_id(request)
|
||||
return web.json_response(list_engines(org_id))
|
||||
|
||||
@routes.put("/v1/engines/default")
|
||||
async def set_default_engine(request):
|
||||
org_id = server._get_org_id(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
engine_id = body.get("engine")
|
||||
if not engine_id:
|
||||
return web.json_response({"error": "engine is required"}, status=400)
|
||||
valid = {e["id"] for e in list_engines(org_id)["engines"]}
|
||||
if engine_id not in valid:
|
||||
return web.json_response(
|
||||
{"error": f"unknown engine {engine_id!r}", "engines": sorted(valid)}, status=400
|
||||
)
|
||||
compute_config.update_config(org_id, {"default_engine": engine_id})
|
||||
return web.json_response(list_engines(org_id))
|
||||
@@ -59,6 +59,17 @@ async def route_prompt(
|
||||
if device_pref == "cpu":
|
||||
return None
|
||||
|
||||
# Engine selector: if the org picked a specific worker as its default
|
||||
# engine, route there. `local` (the default) leaves current behavior
|
||||
# unchanged. See middleware/engine_selector.py.
|
||||
from middleware.engine_selector import resolve_engine_worker
|
||||
engine_worker = resolve_engine_worker(org_id)
|
||||
if engine_worker is not None:
|
||||
result = await forward_to_worker(engine_worker, json_data)
|
||||
if result is not None:
|
||||
return {"action": "forward", "worker": engine_worker, "response": result}
|
||||
logger.warning("Default engine %s failed, falling through", engine_worker.worker_id)
|
||||
|
||||
config = load_config(org_id)
|
||||
|
||||
# No GPU enabled in profile — execute locally
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
SqlitePromptQueue — Hanzo Studio's crash-durable render queue.
|
||||
|
||||
Drop-in for execution.PromptQueue, backed by a single SQLite file (stdlib
|
||||
sqlite3, WAL). When STUDIO_QUEUE_DB is set, render jobs survive a process
|
||||
crash: pending and in-flight prompts live in the DB, and a killed render is
|
||||
rescheduled for another claim instead of being lost. Zero external processes.
|
||||
|
||||
The PromptQueue seam maps straight onto a durable job state machine:
|
||||
|
||||
put(item) → INSERT job (pending) # idempotent on prompt_id
|
||||
get(timeout) → claim next pending (claimed, leased)
|
||||
task_done(...success) → job → done
|
||||
task_done(...error) → job → pending (retry) | failed (per max attempts)
|
||||
|
||||
The existing prompt_worker thread is the claim loop unchanged: it calls get()
|
||||
(claim) and task_done() (report). A background heartbeat renews the lease of
|
||||
the in-flight job while the render runs; if this process dies, the lease
|
||||
expires and the reap step (run on every get() and by the heartbeat thread)
|
||||
returns the job to `pending` so it is re-claimed. A render retry is
|
||||
idempotent — SaveImage suffixes increment.
|
||||
|
||||
Multiple Studio processes pointing at the same STUDIO_QUEUE_DB compete safely:
|
||||
claim runs under BEGIN IMMEDIATE, so exactly one process claims each job.
|
||||
|
||||
History and flags stay in the in-memory base (the Studio UI reads them); only
|
||||
the queue mechanics become durable. This supersedes STUDIO_PERSIST_QUEUE (JSON
|
||||
snapshot) as the local durable path; that remains the zero-dep fallback.
|
||||
Standard library only — no new hard dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
|
||||
from execution import PromptQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_QUEUE = "studio-render"
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS render_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
queue TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
state TEXT NOT NULL, -- pending|claimed|done|failed
|
||||
attempt INTEGER NOT NULL DEFAULT 1,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
worker TEXT,
|
||||
lease_expiry REAL,
|
||||
result TEXT,
|
||||
cause TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_render_jobs_ready ON render_jobs(queue, state, created_at);
|
||||
"""
|
||||
|
||||
|
||||
def serialize_item(item: tuple) -> str:
|
||||
"""Render-queue tuple → JSON job payload."""
|
||||
number, prompt_id, prompt, extra_data, outputs, sensitive = item
|
||||
return json.dumps({
|
||||
"number": number,
|
||||
"prompt_id": prompt_id,
|
||||
"prompt": prompt,
|
||||
"extra_data": extra_data,
|
||||
"outputs": outputs,
|
||||
"sensitive": sensitive,
|
||||
})
|
||||
|
||||
|
||||
def deserialize_item(payload: str) -> tuple:
|
||||
"""JSON job payload → render-queue tuple (inverse of serialize_item)."""
|
||||
d = json.loads(payload)
|
||||
return (
|
||||
d.get("number", 0),
|
||||
d["prompt_id"],
|
||||
d["prompt"],
|
||||
d.get("extra_data") or {},
|
||||
d.get("outputs") or [],
|
||||
d.get("sensitive") or {},
|
||||
)
|
||||
|
||||
|
||||
def _worker_identity() -> str:
|
||||
return os.environ.get("STUDIO_WORKER_ID") or f"{socket.gethostname()}-{os.getpid()}"
|
||||
|
||||
|
||||
class SqlitePromptQueue(PromptQueue):
|
||||
"""PromptQueue whose durability lives in a SQLite file."""
|
||||
|
||||
def __init__(self, server, db_path: str | None = None):
|
||||
super().__init__(server)
|
||||
# SQLite is the durable backend — disable the JSON snapshot path and
|
||||
# drop anything a stray STUDIO_PERSIST_QUEUE restore left behind
|
||||
# (precedence: SQLite > PERSIST > memory).
|
||||
self._persist_path = None
|
||||
self.queue = []
|
||||
|
||||
self.db_path = db_path or os.environ["STUDIO_QUEUE_DB"]
|
||||
self.queue_name = os.environ.get("STUDIO_TASKS_QUEUE", DEFAULT_QUEUE)
|
||||
self.worker_id = _worker_identity()
|
||||
self.max_attempts = int(os.environ.get("STUDIO_TASKS_MAX_ATTEMPTS", "3"))
|
||||
self.lease_s = float(os.environ.get("STUDIO_TASKS_LEASE_MS", "60000")) / 1000.0
|
||||
self.poll_interval = float(os.environ.get("STUDIO_TASKS_POLL_INTERVAL", "1.0"))
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(self.db_path)), exist_ok=True)
|
||||
self._db_lock = threading.Lock()
|
||||
self._conn = sqlite3.connect(self.db_path, check_same_thread=False, isolation_level=None)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._conn.execute("PRAGMA busy_timeout=5000")
|
||||
self._conn.executescript(_SCHEMA)
|
||||
|
||||
# item_id → job_id so task_done reports to the right row.
|
||||
self._job_by_item: dict[int, str] = {}
|
||||
self._pending = 0
|
||||
self._reap() # recover leases orphaned by a previous crash
|
||||
|
||||
logger.info(
|
||||
"[sqlite-queue] durable render queue: db=%s queue=%s worker=%s lease=%.0fs",
|
||||
self.db_path, self.queue_name, self.worker_id, self.lease_s,
|
||||
)
|
||||
|
||||
self._hb_interval = max(self.lease_s / 3.0, 2.0)
|
||||
self._hb_stop = threading.Event()
|
||||
self._hb_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
|
||||
self._hb_thread.start()
|
||||
|
||||
# ── producer: /prompt → durable job ──────────────────────────────
|
||||
def put(self, item):
|
||||
prompt_id = item[1]
|
||||
now = time.time()
|
||||
with self._db_lock:
|
||||
self._conn.execute(
|
||||
"INSERT OR IGNORE INTO render_jobs"
|
||||
"(id, queue, payload, state, attempt, max_attempts, created_at, updated_at)"
|
||||
" VALUES(?,?,?,'pending',1,?,?,?)",
|
||||
(prompt_id, self.queue_name, serialize_item(item), self.max_attempts, now, now),
|
||||
)
|
||||
self.server.queue_updated()
|
||||
|
||||
# ── consumer: prompt_worker → claim ──────────────────────────────
|
||||
def get(self, timeout=None):
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
while True:
|
||||
self._reap()
|
||||
claimed = self._claim()
|
||||
if claimed is not None:
|
||||
return claimed
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
return None
|
||||
wait = self.poll_interval
|
||||
if deadline is not None:
|
||||
wait = min(wait, max(deadline - time.monotonic(), 0.0))
|
||||
if self._hb_stop.wait(wait):
|
||||
return None
|
||||
|
||||
def _claim(self):
|
||||
now = time.time()
|
||||
with self._db_lock:
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = self._conn.execute(
|
||||
"SELECT id, payload FROM render_jobs"
|
||||
" WHERE queue=? AND state='pending' ORDER BY created_at LIMIT 1",
|
||||
(self.queue_name,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
self._conn.execute("COMMIT")
|
||||
return None
|
||||
job_id, payload = row
|
||||
self._conn.execute(
|
||||
"UPDATE render_jobs SET state='claimed', worker=?, lease_expiry=?, updated_at=?"
|
||||
" WHERE id=?",
|
||||
(self.worker_id, now + self.lease_s, now, job_id),
|
||||
)
|
||||
self._conn.execute("COMMIT")
|
||||
except Exception:
|
||||
self._conn.execute("ROLLBACK")
|
||||
raise
|
||||
try:
|
||||
item = deserialize_item(payload)
|
||||
except Exception as e: # corrupt payload — fail terminally, don't loop.
|
||||
logger.error("[sqlite-queue] undecodable job %s: %s", job_id, e)
|
||||
self._terminal(job_id, "failed", cause=f"undecodable payload: {e}")
|
||||
return None
|
||||
with self.mutex:
|
||||
item_id = self.task_counter
|
||||
self.currently_running[item_id] = copy.deepcopy(item)
|
||||
self._job_by_item[item_id] = job_id
|
||||
self.task_counter += 1
|
||||
self.server.queue_updated()
|
||||
return (item, item_id)
|
||||
|
||||
# ── completion → durable state ───────────────────────────────────
|
||||
def task_done(self, item_id, history_result, status, process_item=None):
|
||||
with self.mutex:
|
||||
job_id = self._job_by_item.pop(item_id, None)
|
||||
if job_id is not None:
|
||||
success = status is None or status.status_str == "success"
|
||||
if success:
|
||||
self._terminal(job_id, "done",
|
||||
result=json.dumps({"outputs": _summarize_outputs(history_result)}))
|
||||
else:
|
||||
cause = "; ".join(status.messages) if status and status.messages else "render error"
|
||||
self._fail(job_id, cause)
|
||||
super().task_done(item_id, history_result, status, process_item)
|
||||
|
||||
def _terminal(self, job_id, state, result=None, cause=None):
|
||||
with self._db_lock:
|
||||
self._conn.execute(
|
||||
"UPDATE render_jobs SET state=?, result=?, cause=?, worker=NULL,"
|
||||
" lease_expiry=NULL, updated_at=? WHERE id=?",
|
||||
(state, result, cause, time.time(), job_id),
|
||||
)
|
||||
|
||||
def _fail(self, job_id, cause):
|
||||
# Retry-aware: another attempt if the policy allows, else terminal.
|
||||
with self._db_lock:
|
||||
self._conn.execute(
|
||||
"UPDATE render_jobs SET"
|
||||
" attempt = attempt + 1,"
|
||||
" state = CASE WHEN attempt + 1 > max_attempts THEN 'failed' ELSE 'pending' END,"
|
||||
" worker=NULL, lease_expiry=NULL, cause=?, updated_at=?"
|
||||
" WHERE id=? AND state='claimed'",
|
||||
(cause, time.time(), job_id),
|
||||
)
|
||||
|
||||
# ── crash-recovery: reclaim expired leases ───────────────────────
|
||||
def _reap(self):
|
||||
now = time.time()
|
||||
with self._db_lock:
|
||||
self._conn.execute(
|
||||
"UPDATE render_jobs SET"
|
||||
" attempt = attempt + 1,"
|
||||
" state = CASE WHEN attempt + 1 > max_attempts THEN 'failed' ELSE 'pending' END,"
|
||||
" cause = CASE WHEN attempt + 1 > max_attempts THEN 'lease expired' ELSE cause END,"
|
||||
" worker=NULL, lease_expiry=NULL, updated_at=?"
|
||||
" WHERE state='claimed' AND lease_expiry IS NOT NULL AND lease_expiry < ?",
|
||||
(now, now),
|
||||
)
|
||||
|
||||
# ── heartbeat / pending refresh ──────────────────────────────────
|
||||
def _heartbeat_loop(self):
|
||||
while not self._hb_stop.wait(self._hb_interval):
|
||||
now = time.time()
|
||||
with self.mutex:
|
||||
inflight = list(self._job_by_item.values())
|
||||
if inflight:
|
||||
with self._db_lock:
|
||||
for job_id in inflight:
|
||||
self._conn.execute(
|
||||
"UPDATE render_jobs SET lease_expiry=?, updated_at=?"
|
||||
" WHERE id=? AND state='claimed'",
|
||||
(now + self.lease_s, now, job_id),
|
||||
)
|
||||
self._reap()
|
||||
with self._db_lock:
|
||||
(self._pending,) = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM render_jobs WHERE queue=? AND state='pending'",
|
||||
(self.queue_name,),
|
||||
).fetchone()
|
||||
|
||||
def get_tasks_remaining(self):
|
||||
with self.mutex:
|
||||
return len(self.currently_running) + self._pending
|
||||
|
||||
def job_state(self, job_id: str):
|
||||
"""Diagnostic: current (state, attempt) of a job, or None."""
|
||||
with self._db_lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT state, attempt FROM render_jobs WHERE id=?", (job_id,)
|
||||
).fetchone()
|
||||
return row
|
||||
|
||||
def stop(self):
|
||||
self._hb_stop.set()
|
||||
with self._db_lock:
|
||||
self._conn.close()
|
||||
|
||||
|
||||
def _summarize_outputs(history_result) -> list:
|
||||
"""Extract output filenames from an execution's history_result."""
|
||||
files = []
|
||||
try:
|
||||
outputs = (history_result or {}).get("outputs", {})
|
||||
for node_output in outputs.values():
|
||||
for key in ("images", "gifs", "audio", "video"):
|
||||
for entry in node_output.get(key, []) or []:
|
||||
name = entry.get("filename")
|
||||
if name:
|
||||
files.append({"filename": name, "subfolder": entry.get("subfolder", ""),
|
||||
"type": entry.get("type", "output")})
|
||||
except Exception:
|
||||
pass
|
||||
return files
|
||||
@@ -215,7 +215,15 @@ class PromptServer():
|
||||
self.node_replace_manager = NodeReplaceManager()
|
||||
self.internal_routes = InternalRoutes(self)
|
||||
self.supports = ["custom_nodes_from_web"]
|
||||
self.prompt_queue = execution.PromptQueue(self)
|
||||
# Queue backend precedence: SQLite (durable, zero external process) >
|
||||
# PERSIST (JSON snapshot) > memory. STUDIO_QUEUE_DB opts into the
|
||||
# crash-durable SQLite queue; without it Studio behaves exactly as
|
||||
# before (in-memory + optional JSON snapshot).
|
||||
if os.environ.get("STUDIO_QUEUE_DB"):
|
||||
from middleware.tasks_queue import SqlitePromptQueue
|
||||
self.prompt_queue = SqlitePromptQueue(self)
|
||||
else:
|
||||
self.prompt_queue = execution.PromptQueue(self)
|
||||
self.loop = loop
|
||||
self.messages = asyncio.Queue()
|
||||
self.client_session:Optional[aiohttp.ClientSession] = None
|
||||
@@ -1292,6 +1300,8 @@ class PromptServer():
|
||||
self.custom_node_manager.add_routes(self.routes, self.app, nodes.LOADED_MODULE_DIRS.items())
|
||||
self.subgraph_manager.add_routes(self.routes, nodes.LOADED_MODULE_DIRS.items())
|
||||
self.node_replace_manager.add_routes(self.routes)
|
||||
from middleware.engine_selector import add_engine_routes
|
||||
add_engine_routes(self.routes, self)
|
||||
self.app.add_subapp('/internal', self.internal_routes.get_app())
|
||||
|
||||
# Prefix every route with /api for easier matching for delegation.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Standalone tests for the engine selector (/v1/engines backing logic).
|
||||
Thin wrapper over the per-org compute registry — no GPU, no torch, no network.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
import folder_paths
|
||||
from middleware import compute_config, engine_selector
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(folder_paths, "get_user_directory", lambda: str(tmp_path))
|
||||
return "acme"
|
||||
|
||||
|
||||
def test_local_always_present(org):
|
||||
out = engine_selector.list_engines(org)
|
||||
ids = [e["id"] for e in out["engines"]]
|
||||
assert ids == ["local"]
|
||||
assert out["default"] == "local"
|
||||
|
||||
|
||||
def test_registered_worker_listed(org):
|
||||
compute_config.register_worker(org, {
|
||||
"worker_id": "gb10-1", "url": "http://gb10:8188",
|
||||
"device": "cuda", "status": "ready", "gpu_model": "GB10", "vram_gb": 96,
|
||||
})
|
||||
out = engine_selector.list_engines(org)
|
||||
ids = {e["id"] for e in out["engines"]}
|
||||
assert ids == {"local", "gb10-1"}
|
||||
gb10 = next(e for e in out["engines"] if e["id"] == "gb10-1")
|
||||
assert gb10["type"] == "worker" and gb10["status"] == "ready" and gb10["device"] == "cuda"
|
||||
|
||||
|
||||
def test_set_and_resolve_default(org):
|
||||
compute_config.register_worker(org, {
|
||||
"worker_id": "gb10-1", "url": "http://gb10:8188", "device": "cuda", "status": "ready",
|
||||
})
|
||||
compute_config.update_config(org, {"default_engine": "gb10-1"})
|
||||
assert engine_selector.list_engines(org)["default"] == "gb10-1"
|
||||
w = engine_selector.resolve_engine_worker(org)
|
||||
assert w is not None and w.worker_id == "gb10-1"
|
||||
|
||||
|
||||
def test_default_local_resolves_none(org):
|
||||
assert engine_selector.resolve_engine_worker(org) is None
|
||||
|
||||
|
||||
def test_stale_default_falls_back_to_local(org):
|
||||
# Default names a worker that never registered → list reports local, no 500.
|
||||
compute_config.update_config(org, {"default_engine": "ghost"})
|
||||
assert engine_selector.list_engines(org)["default"] == "local"
|
||||
assert engine_selector.resolve_engine_worker(org) is None
|
||||
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Standalone tests for SqlitePromptQueue — Hanzo Studio's crash-durable render
|
||||
queue. A fake torch-free `execution` module supplies the PromptQueue base, so
|
||||
this runs with no GPU, no torch, and no external process (stdlib sqlite3 only).
|
||||
|
||||
Covers the queue seam (put/get/task_done), exactly-once claim, retry-on-error,
|
||||
and — the headline — crash-recovery: a job claimed and then abandoned by a
|
||||
"crashed" queue instance is reaped and re-run to completion by a fresh instance
|
||||
on the same DB file.
|
||||
"""
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from collections import namedtuple
|
||||
|
||||
import pytest
|
||||
|
||||
ExecStatus = namedtuple("ExecStatus", ["status_str", "completed", "messages"])
|
||||
|
||||
|
||||
def _fake_execution_module():
|
||||
mod = types.ModuleType("execution")
|
||||
|
||||
class PromptQueue:
|
||||
def __init__(self, server):
|
||||
self.server = server
|
||||
self.mutex = threading.RLock()
|
||||
self.task_counter = 0
|
||||
self.queue = []
|
||||
self.currently_running = {}
|
||||
self.history = {}
|
||||
self.flags = {}
|
||||
self._persist_path = None
|
||||
|
||||
def task_done(self, item_id, history_result, status, process_item=None):
|
||||
self.currently_running.pop(item_id, None)
|
||||
|
||||
mod.PromptQueue = PromptQueue
|
||||
return mod
|
||||
|
||||
|
||||
class FakeServer:
|
||||
def __init__(self):
|
||||
self.updates = 0
|
||||
|
||||
def queue_updated(self):
|
||||
self.updates += 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tqmod(monkeypatch):
|
||||
monkeypatch.setenv("STUDIO_TASKS_POLL_INTERVAL", "0.05")
|
||||
monkeypatch.setenv("STUDIO_TASKS_LEASE_MS", "300") # short lease → fast reap
|
||||
monkeypatch.setenv("STUDIO_TASKS_MAX_ATTEMPTS", "3")
|
||||
saved = {k: sys.modules.get(k) for k in ("execution", "middleware.tasks_queue")}
|
||||
sys.modules["execution"] = _fake_execution_module()
|
||||
sys.modules.pop("middleware.tasks_queue", None)
|
||||
import middleware.tasks_queue as tq
|
||||
try:
|
||||
yield tq
|
||||
finally:
|
||||
for k, v in saved.items():
|
||||
if v is None:
|
||||
sys.modules.pop(k, None)
|
||||
else:
|
||||
sys.modules[k] = v
|
||||
|
||||
|
||||
def _item(pid="prompt-1"):
|
||||
return (0, pid, {"1": {"class_type": "EmptyImage"}}, {"org_id": "acme"}, ["1"], {})
|
||||
|
||||
|
||||
def _queue(tqmod, tmp_path, freeze_hb=True):
|
||||
import os
|
||||
os.environ["STUDIO_QUEUE_DB"] = str(tmp_path / "queue.db")
|
||||
q = tqmod.SqlitePromptQueue(FakeServer())
|
||||
if freeze_hb:
|
||||
q._hb_stop.set() # deterministic: no background reaper/heartbeat
|
||||
return q
|
||||
|
||||
|
||||
def test_serde_roundtrip(tqmod):
|
||||
assert tqmod.deserialize_item(tqmod.serialize_item(_item())) == _item()
|
||||
|
||||
|
||||
def test_put_get_task_done(tqmod, tmp_path):
|
||||
q = _queue(tqmod, tmp_path)
|
||||
q.put(_item())
|
||||
assert q.server.updates == 1
|
||||
item, item_id = q.get(timeout=1.0)
|
||||
assert item == _item()
|
||||
assert q.currently_running[item_id] == _item()
|
||||
assert q.job_state("prompt-1")[0] == "claimed"
|
||||
q.task_done(item_id, {"outputs": {"2": {"images": [{"filename": "x_00001_.png"}]}}},
|
||||
ExecStatus("success", True, []))
|
||||
assert q.job_state("prompt-1")[0] == "done"
|
||||
assert item_id not in q._job_by_item
|
||||
|
||||
|
||||
def test_put_is_idempotent(tqmod, tmp_path):
|
||||
q = _queue(tqmod, tmp_path)
|
||||
q.put(_item())
|
||||
q.put(_item()) # same prompt_id → INSERT OR IGNORE, no duplicate
|
||||
q.get(timeout=1.0)
|
||||
assert q.get(timeout=0.2) is None # only one job existed
|
||||
|
||||
|
||||
def test_exactly_once_claim(tqmod, tmp_path):
|
||||
q = _queue(tqmod, tmp_path)
|
||||
q.put(_item())
|
||||
first = q.get(timeout=1.0)
|
||||
assert first is not None
|
||||
assert q.get(timeout=0.2) is None # already claimed — no double dispatch
|
||||
|
||||
|
||||
def test_error_retries_then_fails(tqmod, tmp_path):
|
||||
q = _queue(tqmod, tmp_path)
|
||||
q.put(_item()) # max_attempts=3
|
||||
# attempt 1 → error → back to pending (attempt 2)
|
||||
_, item_id = q.get(timeout=1.0)
|
||||
q.task_done(item_id, {}, ExecStatus("error", False, ["boom-1"]))
|
||||
assert q.job_state("prompt-1") == ("pending", 2)
|
||||
# attempt 2 → error → pending (attempt 3)
|
||||
_, item_id = q.get(timeout=1.0)
|
||||
q.task_done(item_id, {}, ExecStatus("error", False, ["boom-2"]))
|
||||
assert q.job_state("prompt-1") == ("pending", 3)
|
||||
# attempt 3 → error → terminal failed
|
||||
_, item_id = q.get(timeout=1.0)
|
||||
q.task_done(item_id, {}, ExecStatus("error", False, ["boom-3"]))
|
||||
assert q.job_state("prompt-1")[0] == "failed"
|
||||
|
||||
|
||||
def test_crash_recovery_reap(tqmod, tmp_path):
|
||||
import time
|
||||
q = _queue(tqmod, tmp_path)
|
||||
q.put(_item())
|
||||
_, _item_id = q.get(timeout=1.0) # claimed, leased
|
||||
assert q.job_state("prompt-1")[0] == "claimed"
|
||||
time.sleep(0.4) # lease (0.3s) expires: worker "crashed"
|
||||
assert q.get(timeout=1.0) is not None # get() reaps + re-claims for a new attempt
|
||||
assert q.job_state("prompt-1") == ("claimed", 2)
|
||||
|
||||
|
||||
def test_crash_recovery_across_instances(tqmod, tmp_path):
|
||||
"""The headline: a job in flight when a queue instance dies is picked up
|
||||
and completed by a fresh instance on the same DB file."""
|
||||
import time
|
||||
db = str(tmp_path / "queue.db")
|
||||
import os
|
||||
os.environ["STUDIO_QUEUE_DB"] = db
|
||||
|
||||
# Instance A: submit, claim, then "crash" (drop the instance without completing).
|
||||
a = tqmod.SqlitePromptQueue(FakeServer())
|
||||
a._hb_stop.set()
|
||||
a.put(_item())
|
||||
a.get(timeout=1.0)
|
||||
assert a.job_state("prompt-1")[0] == "claimed"
|
||||
a.stop() # process dies mid-render
|
||||
|
||||
time.sleep(0.4) # lease expires
|
||||
|
||||
# Instance B: same DB. Recovers the orphaned job and finishes it.
|
||||
b = tqmod.SqlitePromptQueue(FakeServer()) # __init__ reaps on boot
|
||||
b._hb_stop.set()
|
||||
item, item_id = b.get(timeout=1.0)
|
||||
assert item == _item()
|
||||
assert b.job_state("prompt-1") == ("claimed", 2) # attempt 2 on the new worker
|
||||
b.task_done(item_id, {"outputs": {}}, ExecStatus("success", True, []))
|
||||
assert b.job_state("prompt-1")[0] == "done"
|
||||
b.stop()
|
||||
Reference in New Issue
Block a user