add queue system
Build and Push Containers / test (push) Successful in 9s
Build and Push Containers / build-and-push (push) Successful in 18s

This commit is contained in:
2026-08-01 21:42:38 -04:00
parent 2b544c21ae
commit cd480430b3
7 changed files with 224 additions and 5 deletions
+1
View File
@@ -1,3 +1,4 @@
OPENAI_API_KEY=api-key-here OPENAI_API_KEY=api-key-here
OPENAI_API_URL=https://aipi.reeseapps.com/v1/chat/completions OPENAI_API_URL=https://aipi.reeseapps.com/v1/chat/completions
MODEL=turbo MODEL=turbo
VALKEY_URL=redis://localhost:6379/0
+2 -1
View File
@@ -9,7 +9,8 @@ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev RUN uv sync --frozen --no-dev
COPY app.py ./ COPY app.py ./
COPY gunicorn.conf.py ./
EXPOSE 5000 EXPOSE 5000
CMD ["uv", "run", "--no-sync", "gunicorn", "app:app", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "300"] CMD ["uv", "run", "--no-sync", "gunicorn", "-c", "gunicorn.conf.py", "app:app"]
+186 -2
View File
@@ -5,12 +5,14 @@ import logging
import os import os
import random import random
import re import re
import threading
import time import time
import uuid import uuid
from collections.abc import Generator from collections.abc import Generator
from typing import Any from typing import Any
import httpx import httpx
import valkey
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import Flask, Request, Response, request, stream_with_context from flask import Flask, Request, Response, request, stream_with_context
@@ -30,6 +32,12 @@ API_URL = os.getenv("OPENAI_API_URL", "https://api.openai.com/v1/chat/completion
API_KEY = os.getenv("OPENAI_API_KEY", "") API_KEY = os.getenv("OPENAI_API_KEY", "")
MODEL = os.getenv("MODEL", "gpt-4o-mini") MODEL = os.getenv("MODEL", "gpt-4o-mini")
VALKEY_URL = os.getenv("VALKEY_URL", "redis://localhost:6379/0")
r = valkey.Valkey.from_url(VALKEY_URL, decode_responses=True) # type: ignore[assignment]
worker_running = False
worker_thread: threading.Thread | None = None
logger.info(f"Using model: {MODEL}") logger.info(f"Using model: {MODEL}")
logger.info(f"API URL: {API_URL}") logger.info(f"API URL: {API_URL}")
logger.info(f"API key present: {bool(API_KEY)}") logger.info(f"API key present: {bool(API_KEY)}")
@@ -125,8 +133,11 @@ const es = new EventSource('/stream' + window.location.search + window.location.
let chars = 0; let chars = 0;
let htmlChunks = []; let htmlChunks = [];
let htmlSize = 0; let htmlSize = 0;
let mode = 'idle';
const interval = setInterval(() => { const interval = setInterval(() => {
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';
}
}, 100); }, 100);
window.__wotf_html = ''; window.__wotf_html = '';
es.onmessage = (e) => { es.onmessage = (e) => {
@@ -147,14 +158,18 @@ es.onmessage = (e) => {
htmlSize = 0; htmlSize = 0;
chars = 0; chars = 0;
} else if (e.data.startsWith('[STATUS:')) { } else if (e.data.startsWith('[STATUS:')) {
const msg = e.data.slice(9); const msg = e.data.slice(8);
if (mode !== 'generating') {
stats.textContent = msg; stats.textContent = msg;
mode = 'status';
}
} else if (e.data === '[ERROR]') { } else if (e.data === '[ERROR]') {
clearInterval(interval); clearInterval(interval);
es.close(); es.close();
titleEl.textContent = 'Error generating page'; titleEl.textContent = 'Error generating page';
document.querySelector('.subtitle').textContent = 'Please refresh and try again'; document.querySelector('.subtitle').textContent = 'Please refresh and try again';
} else { } else {
mode = 'generating';
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);
@@ -463,6 +478,100 @@ THEMES = [
] ]
def enqueue_request(context: dict[str, Any]) -> tuple[str, int]:
request_id = uuid.uuid4().hex[:12]
ctx_key = f"wotf:req:{request_id}:ctx"
r.hset(ctx_key, mapping={k: json.dumps(v) for k, v in context.items()}) # type: ignore[union-attr]
r.set(f"wotf:req:{request_id}:status", "pending") # type: ignore[union-attr]
r.rpush("wotf:queue", request_id) # type: ignore[union-attr]
position_raw = r.llen("wotf:queue") # type: ignore[union-attr]
position: int = int(position_raw) if isinstance(position_raw, (int, float)) else 0
return (request_id, position)
def get_queue_info(request_id: str) -> dict[str, Any]:
queue_items_raw = r.lrange("wotf:queue", 0, -1) # type: ignore[union-attr]
queue_items: list[str] = list(queue_items_raw) if isinstance(queue_items_raw, list) else [] # type: ignore[arg-type]
if request_id in queue_items:
position = queue_items.index(request_id) + 1
else:
position = 0
avg_raw = r.get("wotf:stats:avg_time") # type: ignore[union-attr]
avg_str: str = str(avg_raw) if avg_raw is not None else "15"
avg = float(avg_str)
eta = position * avg
return {"position": position, "avg_time": avg, "eta": eta}
def get_generated_html(request_id: str) -> str:
result = r.get(f"wotf:req:{request_id}:html") # type: ignore[union-attr]
return result if isinstance(result, str) else ""
def worker_loop() -> None:
while worker_running:
request_id: str | None = None
try:
current = r.getset("wotf:processing", "locked") # type: ignore[union-attr]
if current is not None:
time.sleep(0.5)
continue
request_id_raw = r.lpop("wotf:queue") # type: ignore[union-attr]
if request_id_raw is None:
r.delete("wotf:processing") # type: ignore[union-attr]
time.sleep(0.5)
continue
request_id = request_id_raw if isinstance(request_id_raw, str) else None
if request_id is None:
r.delete("wotf:processing") # type: ignore[union-attr]
time.sleep(0.5)
continue
r.set(f"wotf:req:{request_id}:status", "processing") # type: ignore[union-attr]
r.delete(f"wotf:stream:{request_id}:chunks") # type: ignore[union-attr]
ctx_raw = r.hgetall(f"wotf:req:{request_id}:ctx") # type: ignore[union-attr]
ctx: dict[str, Any] = {}
if isinstance(ctx_raw, dict):
for k_str, v_raw in ctx_raw.items(): # type: ignore[union-attr]
k: str = str(k_str) # type: ignore[arg-type]
v: Any = v_raw # type: ignore[assignment]
try:
ctx[k] = json.loads(v) if isinstance(v, str) else v
except json.JSONDecodeError:
ctx[k] = v
start = time.time()
for chunk in stream_llm(ctx):
encoded = base64.b64encode(chunk.encode("utf-8")).decode("ascii")
r.rpush(f"wotf:stream:{request_id}:chunks", encoded) # type: ignore[union-attr]
elapsed = time.time() - start
r.set(f"wotf:req:{request_id}:status", "done") # type: ignore[union-attr]
n_raw = r.get("wotf:stats:gen_count") # type: ignore[union-attr]
n_str: str = str(n_raw) if n_raw is not None else "0"
n = int(n_str)
avg_raw = r.get("wotf:stats:avg_time") # type: ignore[union-attr]
avg_str: str = str(avg_raw) if avg_raw is not None else "15"
avg = float(avg_str)
new_avg = (avg * n + elapsed) / (n + 1) if n > 0 else elapsed
r.set("wotf:stats:avg_time", str(new_avg)) # type: ignore[union-attr]
r.set("wotf:stats:gen_count", str(n + 1)) # type: ignore[union-attr]
r.delete("wotf:processing") # type: ignore[union-attr]
logger.info(f"[worker] Generated {request_id} in {elapsed:.1f}s (avg={new_avg:.1f}s)")
except BaseException as e: # noqa: BLE001
logger.error(f"[worker] Error: {e}")
if request_id:
r.set(f"wotf:req:{request_id}:status", "error") # type: ignore[union-attr]
r.set(f"wotf:req:{request_id}:error", str(e)) # type: ignore[union-attr]
r.delete("wotf:processing") # type: ignore[union-attr]
time.sleep(1)
def build_prompt(context: dict[str, Any]) -> str: def build_prompt(context: dict[str, Any]) -> str:
site_type, theme = random.choice(THEMES) site_type, theme = random.choice(THEMES)
mood = random.choice(["playful", "mysterious", "cozy", "epic", "dreamy", "energetic", "melancholic", "whimsical"]) mood = random.choice(["playful", "mysterious", "cozy", "epic", "dreamy", "energetic", "melancholic", "whimsical"])
@@ -614,8 +723,62 @@ def stream() -> Response:
context = get_user_context(request) context = get_user_context(request)
logger.info(f"Stream request from {request.remote_addr}") logger.info(f"Stream request from {request.remote_addr}")
request_id, position = enqueue_request(context)
logger.info(f"[{request_id}] Enqueued at position {position}")
def generate() -> Generator[str]: def generate() -> Generator[str]:
yield from sse_stream(context) yield "data: [CLEAR]\n\n"
while True:
status_raw = r.get(f"wotf:req:{request_id}:status") # type: ignore[union-attr]
status = status_raw if isinstance(status_raw, str) else "pending"
if status == "error":
error_raw = r.get(f"wotf:req:{request_id}:error") # type: ignore[union-attr]
error_msg = error_raw if isinstance(error_raw, str) else "Unknown error"
yield f"data: [STATUS:Error: {error_msg}\n\n"
yield "data: [ERROR]\n\n"
return
if status == "done":
break
if status == "processing":
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]
if chunks:
break
yield "data: [STATUS:Generating your page...\n\n"
time.sleep(0.5)
continue
info = get_queue_info(request_id)
pos = info["position"]
eta = info["eta"]
if pos == 0:
yield "data: [STATUS:Your turn is next...\n\n"
else:
yield f"data: [STATUS:Position {pos} in line | Est. wait: ~{eta:.0f}s\n\n"
time.sleep(1)
last_idx = 0
while True:
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"
last_idx += len(chunks)
time.sleep(0.05)
else:
status_raw = r.get(f"wotf:req:{request_id}:status") # type: ignore[union-attr]
status = status_raw if isinstance(status_raw, str) else "pending"
if status == "done":
break
time.sleep(0.2)
yield "data: [DONE]\n\n"
yield "data: [RENDER]\n\n"
return Response( return Response(
stream_with_context(generate()), stream_with_context(generate()),
@@ -628,5 +791,26 @@ def stream() -> Response:
) )
def start_worker_if_needed() -> None:
global worker_running, worker_thread
if worker_running:
return
claimed = r.set("wotf:worker_lock", "1", nx=True, ex=300) # type: ignore[union-attr]
if claimed:
worker_running = True
worker_thread = threading.Thread(target=worker_loop, daemon=True)
worker_thread.start()
logger.info("[startup] Worker thread started in this process")
else:
logger.info("[startup] Worker thread already running in another process")
def gunicorn_post_fork(server: Any, worker: Any) -> None:
start_worker_if_needed()
if __name__ == "__main__": if __name__ == "__main__":
worker_running = True
worker_thread = threading.Thread(target=worker_loop, daemon=True)
worker_thread.start()
app.run(host="0.0.0.0", port=5000, debug=True) app.run(host="0.0.0.0", port=5000, debug=True)
+10
View File
@@ -0,0 +1,10 @@
services:
valkey:
image: valkey/valkey:latest
ports:
- "6379:6379"
volumes:
- valkey_data:/data
volumes:
valkey_data:
+11
View File
@@ -0,0 +1,11 @@
from typing import Any
import app
bind = "0.0.0.0:5000"
workers = 2
timeout = 300
def post_fork(server: Any, worker: Any) -> None:
app.gunicorn_post_fork(server, worker)
+1
View File
@@ -10,6 +10,7 @@ dependencies = [
"httpx>=0.28.1", "httpx>=0.28.1",
"lxml>=5.0.0", "lxml>=5.0.0",
"python-dotenv>=1.2.2", "python-dotenv>=1.2.2",
"valkey>=6.0.0",
] ]
[project.scripts] [project.scripts]
Generated
+11
View File
@@ -337,6 +337,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
] ]
[[package]]
name = "valkey"
version = "6.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/ee/7fd930fc712275084722ddd464a0ea296abdb997d2da396320507968daeb/valkey-6.1.1.tar.gz", hash = "sha256:5880792990c6c2b5eb604a5ed5f98f300880b6dd92d123819b66ed54bb259731", size = 4601372, upload-time = "2025-08-11T06:41:10.63Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/a2/252afa4da08c714460f49e943070f86a02931f99f886182765194002fe33/valkey-6.1.1-py3-none-any.whl", hash = "sha256:e2691541c6e1503b53c714ad9a35551ac9b7c0bbac93865f063dbc859a46de92", size = 259474, upload-time = "2025-08-11T06:41:08.769Z" },
]
[[package]] [[package]]
name = "web-on-the-fly" name = "web-on-the-fly"
version = "0.1.0" version = "0.1.0"
@@ -347,6 +356,7 @@ dependencies = [
{ name = "httpx" }, { name = "httpx" },
{ name = "lxml" }, { name = "lxml" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "valkey" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -362,6 +372,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "lxml", specifier = ">=5.0.0" }, { name = "lxml", specifier = ">=5.0.0" },
{ name = "python-dotenv", specifier = ">=1.2.2" }, { name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "valkey", specifier = ">=6.0.0" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]