fix queuing system to break less
This commit is contained in:
@@ -160,14 +160,37 @@ let chars = 0;
|
|||||||
let htmlChunks = [];
|
let htmlChunks = [];
|
||||||
let htmlSize = 0;
|
let htmlSize = 0;
|
||||||
let mode = 'idle';
|
let mode = 'idle';
|
||||||
|
let requestId = null;
|
||||||
|
let heartbeatInterval = null;
|
||||||
|
let waitingConfirmStart = null;
|
||||||
|
let statusStart = null;
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (mode === 'generating') {
|
if (mode === 'generating') {
|
||||||
stats.textContent = 'Characters: ' + chars.toLocaleString() + ' | ' + ((Date.now() - start) / 1000).toFixed(1) + 's';
|
stats.textContent = 'Characters: ' + chars.toLocaleString() + ' | ' + ((Date.now() - start) / 1000).toFixed(1) + 's';
|
||||||
}
|
}
|
||||||
|
if (mode === 'waiting_confirmation' && waitingConfirmStart && Date.now() - waitingConfirmStart > 10000) {
|
||||||
|
es.close();
|
||||||
|
clearInterval(interval);
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
titleEl.textContent = 'Connection lost';
|
||||||
|
document.querySelector('.subtitle').textContent = 'Could not connect to worker. Please refresh.';
|
||||||
|
retryBtn.style.display = 'inline-block';
|
||||||
|
}
|
||||||
|
if (mode === 'status' && statusStart && Date.now() - statusStart > 60000) {
|
||||||
|
es.close();
|
||||||
|
clearInterval(interval);
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
titleEl.textContent = 'Connection lost';
|
||||||
|
document.querySelector('.subtitle').textContent = 'Could not connect to worker. Please refresh.';
|
||||||
|
retryBtn.style.display = 'inline-block';
|
||||||
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
es.close();
|
es.close();
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
titleEl.textContent = 'Request timed out';
|
titleEl.textContent = 'Request timed out';
|
||||||
document.querySelector('.subtitle').textContent = 'Generation took too long. Please try again.';
|
document.querySelector('.subtitle').textContent = 'Generation took too long. Please try again.';
|
||||||
retryBtn.style.display = 'inline-block';
|
retryBtn.style.display = 'inline-block';
|
||||||
@@ -175,8 +198,12 @@ const timeout = setTimeout(() => {
|
|||||||
window.__wotf_html = '';
|
window.__wotf_html = '';
|
||||||
es.onmessage = (e) => {
|
es.onmessage = (e) => {
|
||||||
if (e.data === '[DONE]') {
|
if (e.data === '[DONE]') {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = null;
|
||||||
titleEl.textContent = 'Validating HTML...';
|
titleEl.textContent = 'Validating HTML...';
|
||||||
} else if (e.data === '[RENDER]') {
|
} else if (e.data === '[RENDER]') {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = null;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
es.close();
|
es.close();
|
||||||
@@ -191,13 +218,35 @@ es.onmessage = (e) => {
|
|||||||
htmlChunks = [];
|
htmlChunks = [];
|
||||||
htmlSize = 0;
|
htmlSize = 0;
|
||||||
chars = 0;
|
chars = 0;
|
||||||
|
} else if (e.data.startsWith('[ID:')) {
|
||||||
|
requestId = e.data.slice(4, -1);
|
||||||
} else if (e.data.startsWith('[STATUS:')) {
|
} else if (e.data.startsWith('[STATUS:')) {
|
||||||
const msg = e.data.slice(8);
|
const msg = e.data.slice(8);
|
||||||
if (mode !== 'generating') {
|
const cleanMsg = msg.endsWith(']') ? msg.slice(0, -1) : msg;
|
||||||
stats.textContent = msg;
|
if (cleanMsg === 'waiting_confirmation') {
|
||||||
|
mode = 'waiting_confirmation';
|
||||||
|
waitingConfirmStart = Date.now();
|
||||||
|
if (requestId && !window.__confirmed) {
|
||||||
|
window.__confirmed = true;
|
||||||
|
fetch('/confirm/' + requestId, { method: 'POST' }).catch(() => {
|
||||||
|
es.close();
|
||||||
|
clearInterval(interval);
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
titleEl.textContent = 'Connection to worker failed';
|
||||||
|
document.querySelector('.subtitle').textContent = 'Could not connect to generation worker. Please refresh.';
|
||||||
|
retryBtn.style.display = 'inline-block';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (mode !== 'generating') {
|
||||||
|
stats.textContent = cleanMsg;
|
||||||
mode = 'status';
|
mode = 'status';
|
||||||
|
waitingConfirmStart = null;
|
||||||
|
statusStart = Date.now();
|
||||||
}
|
}
|
||||||
} else if (e.data === '[ERROR]') {
|
} else if (e.data === '[ERROR]') {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = null;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
es.close();
|
es.close();
|
||||||
@@ -206,6 +255,12 @@ es.onmessage = (e) => {
|
|||||||
retryBtn.style.display = 'inline-block';
|
retryBtn.style.display = 'inline-block';
|
||||||
} else {
|
} else {
|
||||||
mode = 'generating';
|
mode = 'generating';
|
||||||
|
statusStart = null;
|
||||||
|
if (!heartbeatInterval && requestId) {
|
||||||
|
heartbeatInterval = setInterval(() => {
|
||||||
|
fetch('/heartbeat/' + requestId, { method: 'POST' });
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
const bytes = Uint8Array.from(atob(e.data), c => c.charCodeAt(0));
|
const bytes = Uint8Array.from(atob(e.data), c => c.charCodeAt(0));
|
||||||
const decoded = new TextDecoder().decode(bytes);
|
const decoded = new TextDecoder().decode(bytes);
|
||||||
htmlChunks.push(decoded);
|
htmlChunks.push(decoded);
|
||||||
@@ -214,6 +269,8 @@ es.onmessage = (e) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = null;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
es.close();
|
es.close();
|
||||||
@@ -589,9 +646,42 @@ def worker_loop() -> None:
|
|||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
r.set(f"wotf:req:{request_id}:status", "processing") # type: ignore[union-attr]
|
r.set(f"wotf:req:{request_id}:status", "waiting_confirmation") # type: ignore[union-attr]
|
||||||
r.delete(f"wotf:stream:{request_id}:chunks") # type: ignore[union-attr]
|
r.delete(f"wotf:stream:{request_id}:chunks") # type: ignore[union-attr]
|
||||||
|
|
||||||
|
r.rpush(f"wotf:stream:{request_id}:chunks", f"[ID:{request_id}]") # type: ignore[union-attr]
|
||||||
|
r.rpush(f"wotf:stream:{request_id}:chunks", "[STATUS:waiting_confirmation]") # type: ignore[union-attr]
|
||||||
|
|
||||||
|
r.delete("wotf:processing") # type: ignore[union-attr]
|
||||||
|
r.delete(hb_key) # type: ignore[union-attr]
|
||||||
|
|
||||||
|
confirmed = False
|
||||||
|
for _ in range(150):
|
||||||
|
time.sleep(0.1)
|
||||||
|
if r.get(f"wotf:req:{request_id}:confirmed"): # type: ignore[union-attr]
|
||||||
|
confirmed = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not confirmed:
|
||||||
|
logger.info(f"[worker] No confirmation for {request_id}, marking as error")
|
||||||
|
r.set(f"wotf:req:{request_id}:status", "error") # type: ignore[union-attr]
|
||||||
|
r.set(f"wotf:req:{request_id}:error", "Worker did not receive confirmation from client") # type: ignore[union-attr]
|
||||||
|
r.rpush(f"wotf:stream:{request_id}:chunks", "[STATUS:Connection to worker failed]") # type: ignore[union-attr]
|
||||||
|
r.rpush(f"wotf:stream:{request_id}:chunks", "[ERROR]") # type: ignore[union-attr]
|
||||||
|
r.delete(f"wotf:req:{request_id}:confirmed") # type: ignore[union-attr]
|
||||||
|
r.expire(f"wotf:req:{request_id}:ctx", 30) # type: ignore[union-attr]
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
acquired = r.set("wotf:processing", worker_id, ex=120, nx=True) # type: ignore[union-attr]
|
||||||
|
if not acquired:
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
r.set(hb_key, worker_id, ex=15) # type: ignore[union-attr]
|
||||||
|
r.set(f"wotf:req:{request_id}:status", "processing") # type: ignore[union-attr]
|
||||||
|
r.set(f"wotf:req:{request_id}:last_heartbeat", str(time.time())) # type: ignore[union-attr]
|
||||||
|
|
||||||
ctx_raw = r.hgetall(f"wotf:req:{request_id}:ctx") # type: ignore[union-attr]
|
ctx_raw = r.hgetall(f"wotf:req:{request_id}:ctx") # type: ignore[union-attr]
|
||||||
ctx: dict[str, Any] = {}
|
ctx: dict[str, Any] = {}
|
||||||
if isinstance(ctx_raw, dict):
|
if isinstance(ctx_raw, dict):
|
||||||
@@ -605,12 +695,37 @@ def worker_loop() -> None:
|
|||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
hb_start = time.time()
|
hb_start = time.time()
|
||||||
|
hb_check_start = time.time()
|
||||||
html_buffer: list[str] = []
|
html_buffer: list[str] = []
|
||||||
|
aborted = False
|
||||||
try:
|
try:
|
||||||
for chunk in stream_llm(ctx):
|
for chunk in stream_llm(ctx):
|
||||||
if time.time() - hb_start >= 5:
|
if time.time() - hb_start >= 5:
|
||||||
r.set(hb_key, worker_id, ex=15) # type: ignore[union-attr]
|
r.set(hb_key, worker_id, ex=15) # type: ignore[union-attr]
|
||||||
hb_start = time.time()
|
hb_start = time.time()
|
||||||
|
|
||||||
|
if time.time() - hb_check_start >= 2:
|
||||||
|
hb_check_start = time.time()
|
||||||
|
last_hb_raw = r.get(f"wotf:req:{request_id}:last_heartbeat") # type: ignore[union-attr]
|
||||||
|
if last_hb_raw:
|
||||||
|
last_hb = float(last_hb_raw) # type: ignore[arg-type]
|
||||||
|
if time.time() - last_hb > 10:
|
||||||
|
logger.info(f"[worker] Client disconnected (heartbeat stale), aborting {request_id}")
|
||||||
|
r.set(f"wotf:req:{request_id}:status", "aborted") # type: ignore[union-attr]
|
||||||
|
r.rpush(f"wotf:stream:{request_id}:chunks", "[ERROR]") # type: ignore[union-attr]
|
||||||
|
r.delete("wotf:processing") # type: ignore[union-attr]
|
||||||
|
r.delete(hb_key) # type: ignore[union-attr]
|
||||||
|
aborted = True
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.info(f"[worker] No heartbeat from client, aborting {request_id}")
|
||||||
|
r.set(f"wotf:req:{request_id}:status", "aborted") # type: ignore[union-attr]
|
||||||
|
r.rpush(f"wotf:stream:{request_id}:chunks", "[ERROR]") # type: ignore[union-attr]
|
||||||
|
r.delete("wotf:processing") # type: ignore[union-attr]
|
||||||
|
r.delete(hb_key) # type: ignore[union-attr]
|
||||||
|
aborted = True
|
||||||
|
break
|
||||||
|
|
||||||
html_buffer.append(chunk)
|
html_buffer.append(chunk)
|
||||||
encoded = base64.b64encode(chunk.encode("utf-8")).decode("ascii")
|
encoded = base64.b64encode(chunk.encode("utf-8")).decode("ascii")
|
||||||
r.rpush(f"wotf:stream:{request_id}:chunks", encoded) # type: ignore[union-attr]
|
r.rpush(f"wotf:stream:{request_id}:chunks", encoded) # type: ignore[union-attr]
|
||||||
@@ -632,6 +747,9 @@ def worker_loop() -> None:
|
|||||||
r.delete(hb_key) # type: ignore[union-attr]
|
r.delete(hb_key) # type: ignore[union-attr]
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
if aborted:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
html = "".join(html_buffer)
|
html = "".join(html_buffer)
|
||||||
r.rpush(f"wotf:stream:{request_id}:chunks", "[STATUS:Validating HTML structure...]") # type: ignore[union-attr]
|
r.rpush(f"wotf:stream:{request_id}:chunks", "[STATUS:Validating HTML structure...]") # type: ignore[union-attr]
|
||||||
errors = validate_html(html)
|
errors = validate_html(html)
|
||||||
@@ -770,7 +888,7 @@ def stream_llm(context: dict[str, Any]) -> Generator[str]:
|
|||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
logger.error(f"[{req_id}] HTTP error in {elapsed:.1f}s: {e.response.status_code} {e.response.text[:200]}")
|
logger.error(f"[{req_id}] HTTP error in {elapsed:.1f}s: {e.response.status_code} {e.response.text[:200]}")
|
||||||
yield f"\n\n<!-- Error: {e.response.status_code} -->"
|
yield f"\n\n<!-- Error: {e.response.status_code} -->"
|
||||||
except BaseException as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
logger.error(f"[{req_id}] Stream failed in {elapsed:.1f}s: {e}")
|
logger.error(f"[{req_id}] Stream failed in {elapsed:.1f}s: {e}")
|
||||||
yield f"\n\n<!-- Error: {e} -->"
|
yield f"\n\n<!-- Error: {e} -->"
|
||||||
@@ -842,6 +960,7 @@ def stream() -> Response:
|
|||||||
def generate() -> Generator[str]:
|
def generate() -> Generator[str]:
|
||||||
yield "data: [CLEAR]\n\n"
|
yield "data: [CLEAR]\n\n"
|
||||||
|
|
||||||
|
last_idx = 0
|
||||||
while True:
|
while True:
|
||||||
status_raw = r.get(f"wotf:req:{request_id}:status") # type: ignore[union-attr]
|
status_raw = r.get(f"wotf:req:{request_id}:status") # type: ignore[union-attr]
|
||||||
status = status_raw if isinstance(status_raw, str) else "pending"
|
status = status_raw if isinstance(status_raw, str) else "pending"
|
||||||
@@ -853,18 +972,36 @@ def stream() -> Response:
|
|||||||
yield "data: [ERROR]\n\n"
|
yield "data: [ERROR]\n\n"
|
||||||
return
|
return
|
||||||
|
|
||||||
if status == "done":
|
if status == "waiting_confirmation":
|
||||||
break
|
|
||||||
|
|
||||||
if status == "processing":
|
|
||||||
chunks_raw = r.lrange(f"wotf:stream:{request_id}:chunks", 0, -1) # type: ignore[union-attr]
|
chunks_raw = r.lrange(f"wotf:stream:{request_id}:chunks", 0, -1) # type: ignore[union-attr]
|
||||||
chunks: list[str] = list(chunks_raw) if isinstance(chunks_raw, list) else [] # type: ignore[arg-type]
|
chunks: list[str] = list(chunks_raw) if isinstance(chunks_raw, list) else [] # type: ignore[arg-type]
|
||||||
if chunks:
|
if chunks:
|
||||||
break
|
for c in chunks:
|
||||||
yield "data: [STATUS:Generating your page...\n\n"
|
yield f"data: {c}\n\n"
|
||||||
time.sleep(0.5)
|
else:
|
||||||
|
yield "data: [STATUS:Waiting for your connection...\n\n"
|
||||||
|
time.sleep(0.2)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if status == "processing":
|
||||||
|
chunks_raw = r.lrange(f"wotf:stream:{request_id}:chunks", last_idx, -1) # type: ignore[union-attr]
|
||||||
|
chunks: list[str] = list(chunks_raw) if isinstance(chunks_raw, list) else [] # type: ignore[arg-type]
|
||||||
|
if chunks:
|
||||||
|
for c in chunks:
|
||||||
|
yield f"data: {c}\n\n"
|
||||||
|
last_idx += len(chunks)
|
||||||
|
else:
|
||||||
|
yield "data: [STATUS:Generating your page...\n\n"
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if status == "done":
|
||||||
|
chunks_raw = r.lrange(f"wotf:stream:{request_id}:chunks", last_idx, -1) # type: ignore[union-attr]
|
||||||
|
chunks: list[str] = list(chunks_raw) if isinstance(chunks_raw, list) else [] # type: ignore[arg-type]
|
||||||
|
for c in chunks:
|
||||||
|
yield f"data: {c}\n\n"
|
||||||
|
break
|
||||||
|
|
||||||
info = get_queue_info(request_id)
|
info = get_queue_info(request_id)
|
||||||
pos = info["position"]
|
pos = info["position"]
|
||||||
eta = info["eta"]
|
eta = info["eta"]
|
||||||
@@ -874,7 +1011,6 @@ def stream() -> Response:
|
|||||||
yield f"data: [STATUS:Position {pos} in line | Est. wait: ~{eta:.0f}s\n\n"
|
yield f"data: [STATUS:Position {pos} in line | Est. wait: ~{eta:.0f}s\n\n"
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
last_idx = 0
|
|
||||||
while True:
|
while True:
|
||||||
chunks_raw = r.lrange(f"wotf:stream:{request_id}:chunks", last_idx, -1) # type: ignore[union-attr]
|
chunks_raw = r.lrange(f"wotf:stream:{request_id}:chunks", last_idx, -1) # type: ignore[union-attr]
|
||||||
chunks: list[str] = list(chunks_raw) if isinstance(chunks_raw, list) else [] # type: ignore[arg-type]
|
chunks: list[str] = list(chunks_raw) if isinstance(chunks_raw, list) else [] # type: ignore[arg-type]
|
||||||
@@ -890,6 +1026,11 @@ def stream() -> Response:
|
|||||||
status_raw = r.get(f"wotf:req:{request_id}:status") # type: ignore[union-attr]
|
status_raw = r.get(f"wotf:req:{request_id}:status") # type: ignore[union-attr]
|
||||||
status = status_raw if isinstance(status_raw, str) else "pending"
|
status = status_raw if isinstance(status_raw, str) else "pending"
|
||||||
if status == "done":
|
if status == "done":
|
||||||
|
chunks_raw = r.lrange(f"wotf:stream:{request_id}:chunks", last_idx, -1) # type: ignore[union-attr]
|
||||||
|
chunks: list[str] = list(chunks_raw) if isinstance(chunks_raw, list) else [] # type: ignore[arg-type]
|
||||||
|
if chunks:
|
||||||
|
for chunk in chunks:
|
||||||
|
yield f"data: {chunk}\n\n"
|
||||||
break
|
break
|
||||||
if status == "error":
|
if status == "error":
|
||||||
error_raw = r.get(f"wotf:req:{request_id}:error") # type: ignore[union-attr]
|
error_raw = r.get(f"wotf:req:{request_id}:error") # type: ignore[union-attr]
|
||||||
@@ -897,6 +1038,10 @@ def stream() -> Response:
|
|||||||
yield f"data: [STATUS:Error: {error_msg}\n\n"
|
yield f"data: [STATUS:Error: {error_msg}\n\n"
|
||||||
yield "data: [ERROR]\n\n"
|
yield "data: [ERROR]\n\n"
|
||||||
return
|
return
|
||||||
|
if status == "aborted":
|
||||||
|
yield "data: [STATUS:Generation aborted (connection lost)\n\n"
|
||||||
|
yield "data: [ERROR]\n\n"
|
||||||
|
return
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
@@ -910,6 +1055,24 @@ def stream() -> Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/confirm/<request_id>", methods=["POST"])
|
||||||
|
def confirm(request_id: str) -> Response:
|
||||||
|
ctx = r.exists(f"wotf:req:{request_id}:ctx") # type: ignore[union-attr]
|
||||||
|
if not ctx:
|
||||||
|
return Response("not found", status=404)
|
||||||
|
r.set(f"wotf:req:{request_id}:confirmed", "1") # type: ignore[union-attr]
|
||||||
|
return Response("ok", status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/heartbeat/<request_id>", methods=["POST"])
|
||||||
|
def heartbeat(request_id: str) -> Response:
|
||||||
|
status = r.get(f"wotf:req:{request_id}:status") # type: ignore[union-attr]
|
||||||
|
if status is None:
|
||||||
|
return Response("not found", status=404)
|
||||||
|
r.set(f"wotf:req:{request_id}:last_heartbeat", str(time.time())) # type: ignore[union-attr]
|
||||||
|
return Response("ok", status=200)
|
||||||
|
|
||||||
|
|
||||||
def start_worker_if_needed() -> None:
|
def start_worker_if_needed() -> None:
|
||||||
global worker_running, worker_thread
|
global worker_running, worker_thread
|
||||||
if worker_running:
|
if worker_running:
|
||||||
|
|||||||
Reference in New Issue
Block a user