Solves are Queued

This has some nice improvements in the case I want to batch a bunch of
stuff overnight.
This commit is contained in:
Pagwin 2026-06-18 23:14:59 -04:00
parent ae7fef5d7b
commit f52871318e
2 changed files with 419 additions and 233 deletions

View file

@ -70,26 +70,6 @@
font-weight: 600; font-weight: 600;
} }
#solveBtn:disabled {
opacity: 50%;
background: #888;
}
#busy {
font-size: 1rem;
font-weight: 600;
}
/* This tab is the one solving. */
#busy.busy-self {
color: #16a34a;
}
/* Another viewer holds the solve lock. */
#busy.busy-other {
color: #d97706;
}
.mini { .mini {
padding: .1rem .45rem; padding: .1rem .45rem;
font-size: .85rem; font-size: .85rem;
@ -285,10 +265,41 @@
padding: .3rem 0; padding: .3rem 0;
} }
.pending { /* A reserved slot for a queued/running solve: dashed to read as "not done
opacity: .75; yet", holding a spinner, its share link, and a Cancel button. */
.solution-pending {
border: 1px dashed #8886;
border-radius: 8px;
padding: .4rem .8rem;
margin-top: .8rem; margin-top: .8rem;
} }
.solution-pending.err {
border-color: #dc2626;
}
.pending-head {
font-weight: 600;
padding: .3rem 0;
}
.spinner {
display: inline-block;
width: .9em;
height: .9em;
border: 2px solid #8886;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin .8s linear infinite;
vertical-align: -.1em;
margin-right: .45rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style> </style>
</head> </head>
@ -386,8 +397,8 @@
<p> <p>
<button id="solveBtn" class="primary" type="button" onclick="run()">Solve</button> <button id="solveBtn" class="primary" type="button" onclick="run()">Solve</button>
<button id="cancelBtn" type="button" onclick="cancelSolve()" style="display:none;margin-left:.4rem">Cancel</button> <button id="shareAllBtn" type="button" onclick="shareVisible()" style="margin-left:.4rem">Share
<span id="busy" class="help" style="margin-left:.6rem"></span> visible solutions</button>
</p> </p>
<div id="error" class="err"></div> <div id="error" class="err"></div>
@ -856,127 +867,208 @@
// them in request order. // them in request order.
let solutionCount = 0; let solutionCount = 0;
// Whether *this* tab is the one currently solving. The /status poll only // Live tracking of this tab's queued/running solves.
// disables the button for OTHER viewers, so the solver keeps its button // token -> {token, card, n, confirmed}. Each gets a reserved "pending" card
// state under run()'s own control. // on the page right away; a shared /job_status event stream drives it through
let solvingHere = false; // queued -> running -> done (rendered) / error / cancelled.
const pending = new Map();
// Token identifying this tab's in-flight solve. When the tab closes mid function uuid() {
// solve we beacon /cancel?token=... so the server stops it and frees the return crypto.randomUUID ? crypto.randomUUID()
// lock immediately — reliable even behind a reverse proxy, which keeps : String(Date.now()) + Math.random();
// the upstream socket open and so hides the disconnect from the server. }
let activeToken = null;
function cancelActiveSolve() { // A share URL for a set of solve tokens. The schema is
if (activeToken && navigator.sendBeacon) { // ?solves=<id1>,<id2>,… so anyone who knows the ids can compose an
navigator.sendBeacon("/cancel?token=" + encodeURIComponent(activeToken)); // arbitrary set; a single ?solve=<id> link is also still understood.
function shareUrl(tokens) {
return location.origin + location.pathname +
"?solves=" + tokens.map(encodeURIComponent).join(",");
}
// A "copy link" button that briefly confirms after writing to the clipboard.
function copyLinkButton(label, getUrl) {
return el("button", {
class: "mini", type: "button",
onclick: (ev) => {
navigator.clipboard?.writeText(getUrl());
const orig = ev.target.textContent;
ev.target.textContent = "Copied!";
setTimeout(() => {ev.target.textContent = orig;}, 1500);
},
}, label);
}
// Copy a single link sharing every solution currently on the page (pending
// ones included — their links resolve once the solve finishes).
function shareVisible() {
const tokens = [...document.querySelectorAll("#output [data-token]")]
.map(e => e.dataset.token);
const btn = document.getElementById("shareAllBtn");
const reset = () => setTimeout(() => {btn.textContent = "Share visible solutions";}, 1500);
if (!tokens.length) {btn.textContent = "Nothing to share"; reset(); return;}
navigator.clipboard?.writeText(shareUrl(tokens));
btn.textContent = "Link copied!";
reset();
}
// Build the reserved card for a queued/running solve: a spinner + status,
// the (already valid) share link, and a Cancel button.
function makePendingCard(token, n) {
const card = el("div", {class: "solution-pending"});
card.dataset.token = token;
const status = el("span", {}, "Queued…");
card._status = status;
const actions = el("p", {}, [
copyLinkButton("Copy share link", () => shareUrl([token])), " ",
el("button", {
class: "mini", type: "button",
onclick: () => cancelPending(token),
}, "Cancel"),
]);
card._actions = actions;
card.append(
el("div", {class: "pending-head"},
[el("span", {class: "spinner"}), `Solution ${n} — `, status]),
actions);
return card;
}
// Cancel a pending solve: drop it from the queue or stop the running search.
// The stream then reports "cancelled" (card removed) or, for a stopped
// running solve, "done" with the best plan found so far (card rendered).
function cancelPending(token) {
const entry = pending.get(token);
if (entry) entry.card._status.textContent = "Cancelling…";
fetch("/cancel?token=" + encodeURIComponent(token), {method: "POST"})
.catch(() => {/* the stream reflects the outcome regardless */});
}
// Stop tracking a pending solve and run a final action on its card. Once
// nothing is pending, close the event stream so it doesn't reconnect.
function finishPending(token, action) {
const entry = pending.get(token);
if (!entry) return;
pending.delete(token);
action(entry);
if (pending.size === 0 && stream) {stream.close(); stream = null;}
}
// Put a pending card into a terminal state: show the message, drop the
// spinner & cancel, and offer a Dismiss button. `isError` tints it red.
function finalizeCard(token, msg, isError = false) {
finishPending(token, (entry) => {
entry.card.classList.toggle("err", isError);
entry.card.querySelector(".spinner")?.remove(); // it's no longer loading
entry.card._status.textContent = msg;
entry.card._actions.replaceChildren(el("button", {
class: "mini", type: "button",
onclick: () => entry.card.remove(),
}, "Dismiss"));
});
}
function pendingError(token, msg) {finalizeCard(token, msg, true);}
// A single Server-Sent Events stream carries progress for every pending
// solve at once: /job_status?tokens=t1,t2,… It's rebuilt whenever the set
// of confirmed (enqueued) solves changes, and closed when none remain.
let stream = null;
function syncStream() {
if (stream) {stream.close(); stream = null;}
const tokens = [...pending.values()].filter(e => e.confirmed).map(e => e.token);
if (!tokens.length) return;
stream = new EventSource(
"/job_status?tokens=" + tokens.map(encodeURIComponent).join(","));
stream.onmessage = (ev) => handleJobEvent(JSON.parse(ev.data));
// On a transient network error EventSource auto-reconnects to the same
// URL; we explicitly close it (in finishPending) once all solves finish.
stream.onerror = () => {/* let the browser retry */};
}
// Advance one pending solve's card from a streamed state update.
function handleJobEvent(j) {
const entry = pending.get(j.token);
if (!entry) return; // already finished/dismissed
switch (j.status) {
case "queued":
entry.card._status.textContent =
j.position > 0 ? `Queued (${j.position} ahead)…` : "Queued (next up)…";
break;
case "running":
entry.card._status.textContent = "Solving…";
break;
case "done":
finishPending(j.token, (e) =>
renderSolution(j.solution, e.card, j.token, e.n));
break;
case "cancelled":
// Leave a visible "cancelled" card (whether this tab cancelled
// it or it was cancelled elsewhere) rather than silently vanishing.
finalizeCard(j.token, "This solve was cancelled.");
break;
case "error":
pendingError(j.token, "Error: " + (j.error || "solve failed"));
break;
default: // "unknown": no such job/solve (evicted, or server restarted)
pendingError(j.token,
`Solve ${j.token} not found (it may have been evicted or the server restarted).`);
} }
} }
// pagehide fires on tab close / navigation away (not on mere tab switch,
// so we don't cancel a solve the user backgrounds and comes back to).
window.addEventListener("pagehide", cancelActiveSolve);
// Cancel this tab's in-flight solve without closing the tab: POST /cancel // Closing or leaving a tab no longer cancels its solves: with the queue,
// so the server stops the search. The running /solve then returns with the // a left-behind solve just runs (or waits its turn) to completion and is
// best plan found so far (or an empty/infeasible one), which run() renders. // stored, so its share link still resolves — and a tab that only *views* a
function cancelSolve() { // shared solve can't cancel the author's work just by being closed. Cancel
if (!activeToken) return; // is now an explicit per-card button only (see cancelPending).
const btn = document.getElementById("cancelBtn");
btn.disabled = true;
btn.textContent = "Cancelling…";
fetch("/cancel?token=" + encodeURIComponent(activeToken), {method: "POST"})
.catch(() => {/* the solve still returns; nothing to do here */});
}
// Toggle the Cancel button's visibility, resetting its label/disabled state.
function showCancel(visible) {
const btn = document.getElementById("cancelBtn");
btn.style.display = visible ? "" : "none";
btn.disabled = false;
btn.textContent = "Cancel";
}
async function run() { // Queue a solve: reserve its card immediately, POST it, then subscribe to
// the progress stream once it's enqueued (confirmed). Multiple solves can
// be queued at once; the server runs them one at a time.
function run() {
const errBox = document.getElementById("error"); const errBox = document.getElementById("error");
const out = document.getElementById("output");
errBox.textContent = ""; errBox.textContent = "";
// A placeholder card for this request, prepended so the newest is on top.
// It's replaced by the solution on success, or removed on error.
const pending = el("p", {class: "pending"}, "Solving…");
out.prepend(pending);
let problem, time; let problem, time;
try { try {
problem = buildProblem(); problem = buildProblem();
time = +document.getElementById("time").value; time = +document.getElementById("time").value;
} catch (e) {errBox.textContent = e.message; pending.remove(); return;} } catch (e) {errBox.textContent = e.message; return;}
solvingHere = true; const token = uuid();
setSolveDisabled(true, "Solving…"); const n = ++solutionCount;
const token = (crypto.randomUUID ? crypto.randomUUID() const card = makePendingCard(token, n);
: String(Date.now()) + Math.random()); document.getElementById("output").prepend(card);
activeToken = token; pending.set(token, {token, card, n, confirmed: false});
showCancel(true); fetch("/solve", {
try { method: "POST",
const resp = await fetch("/solve", { headers: {"Content-Type": "application/json"},
method: "POST", body: JSON.stringify({problem, max_time_seconds: time, token}),
headers: {"Content-Type": "application/json"}, }).then(async (resp) => {
body: JSON.stringify({problem, max_time_seconds: time, token}), if (!resp.ok) {
}); const data = await resp.json().catch(() => ({}));
if (resp.status === 429) { pendingError(token, data.error || "Server error");
pending.remove();
errBox.textContent = "Another viewer is already solving — try again once they finish.";
return; return;
} }
const data = await resp.json(); // Now the server knows the token; (re)open the stream to include it.
if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;} const entry = pending.get(token);
renderSolution(data, pending, token); if (entry) {entry.confirmed = true; syncStream();}
} catch (e) {pending.remove(); errBox.textContent = e.message;} }).catch((e) => pendingError(token, e.message));
finally {solvingHere = false; activeToken = null; showCancel(false); setSolveDisabled(false);}
} }
// Enable/disable the Solve button with an optional note beside it. `kind` function renderSolution(s, placeholder, token, n) {
// ("self" or "other") picks the note's color so this tab's "Solving…" and
// another viewer's "Another viewer is solving…" stand out distinctly.
function setSolveDisabled(disabled, note = "", kind = "self") {
document.getElementById("solveBtn").disabled = disabled;
const busy = document.getElementById("busy");
busy.textContent = note;
busy.classList.toggle("busy-self", kind === "self");
busy.classList.toggle("busy-other", kind === "other");
}
// Poll the server's solve state so that while one viewer is solving the
// others see their Solve button disabled (and re-enabled when it frees up).
async function pollStatus() {
try {
const resp = await fetch("/status", {cache: "no-store"});
const {solving} = await resp.json();
if (!solvingHere) setSolveDisabled(solving,
solving ? "Another viewer is solving…" : "", "other");
} catch (e) {/* leave button as-is on a transient error */}
}
setInterval(pollStatus, 1000);
pollStatus();
function renderSolution(s, placeholder, token) {
// Collapse any previously-shown solutions so the new one is the focus. // Collapse any previously-shown solutions so the new one is the focus.
for (const d of document.querySelectorAll("#output details.solution")) d.open = false; for (const d of document.querySelectorAll("#output details.solution")) d.open = false;
const n = ++solutionCount; n = n ?? ++solutionCount;
const details = el("details", {class: "solution", open: ""}); const details = el("details", {class: "solution", open: ""});
// Tag the card with its token so "Share visible solutions" can collect it.
if (token) details.dataset.token = token;
details.append(el("summary", {}, details.append(el("summary", {},
`Solution ${n} — ${s.status}, objective ${s.objective_value ?? "—"}`)); `Solution ${n} — ${s.status}, objective ${s.objective_value ?? "—"}`));
const out = details; const out = details;
// A shareable permalink to this stored solve, looked up by its UUID. // A shareable permalink to this stored solve, looked up by its UUID.
if (token) { if (token) {
const url = location.origin + location.pathname + "?solve=" + encodeURIComponent(token); out.append(el("p", {}, copyLinkButton("Copy share link",
out.append(el("p", {}, el("button", { () => shareUrl([token]))));
class: "mini", type: "button",
onclick: (ev) => {
navigator.clipboard?.writeText(url);
ev.target.textContent = "Link copied!";
setTimeout(() => {ev.target.textContent = "Copy share link";}, 1500);
},
}, "Copy share link")));
} }
out.append(el("p", { out.append(el("p", {
html: html:
@ -1197,26 +1289,28 @@
"electrum": 2 "electrum": 2
}) })
// --- deep link: /?solve=<uuid> loads a previously stored solve --- // --- deep links ---
async function loadSharedSolve(token) { // /?solve=<id> loads one solve; /?solves=<id1>,<id2>,… loads an arbitrary
const out = document.getElementById("output"); // set (the schema "Share visible solutions" produces). Each id is attached
const errBox = document.getElementById("error"); // to its live job state exactly like a locally-queued solve: a card is
const pending = el("p", {class: "pending"}, "Loading shared solve…"); // reserved synchronously (so the set keeps its given order) and the shared
out.prepend(pending); // /job_status stream resolves it — rendering a finished solve, or tracking
try { // one that's still queued/running until it completes (jobs are server-wide).
const resp = await fetch("/solve/" + encodeURIComponent(token), {cache: "no-store"}); // Only truly-unknown ids (evicted / never existed) fall to the "not found"
if (resp.status === 404) { // message, instead of every in-flight solve 404ing as "not stored yet".
pending.remove(); function loadSharedSolve(token) {
errBox.textContent = "No stored solve found for that link (it may have been evicted)."; const n = ++solutionCount;
return; const card = makePendingCard(token, n);
} card._status.textContent = "Loading…";
const rec = await resp.json(); document.getElementById("output").prepend(card);
if (!resp.ok) {pending.remove(); errBox.textContent = rec.error || "Server error"; return;} pending.set(token, {token, card, n, confirmed: true});
renderSolution(rec.solution, pending, rec.token);
} catch (e) {pending.remove(); errBox.textContent = e.message;}
} }
const sharedToken = new URLSearchParams(location.search).get("solve"); const params = new URLSearchParams(location.search);
if (sharedToken) loadSharedSolve(sharedToken); const sharedTokens = (params.get("solves") || params.get("solve") || "")
.split(",").map(s => s.trim()).filter(Boolean);
// Load in reverse so the first id ends up on top (each load prepends).
for (const token of sharedTokens.slice().reverse()) loadSharedSolve(token);
if (sharedTokens.length) syncStream();
</script> </script>
</body> </body>

282
main.py
View file

@ -14,8 +14,6 @@ from __future__ import annotations
import json import json
import os import os
import select
import socket
import sqlite3 import sqlite3
import threading import threading
import time import time
@ -95,21 +93,83 @@ def fetch_solve(token):
return {"token": row[0], "ts": row[1], "status": row[2], return {"token": row[0], "ts": row[1], "status": row[2],
"problem": json.loads(row[3]), "solution": json.loads(row[4])} "problem": json.loads(row[3]), "solution": json.loads(row[4])}
# Only one solve may run at a time across all viewers. While it's held, other # Solves are queued and run one at a time by a single background worker, so any
# clients get an immediate 429 from /solve and disable their button (they learn # number of viewers can pile requests on without being rejected — each request
# the busy state by polling /status). # returns immediately with a queue position and the client polls /job/<token>
_solve_lock = threading.Lock() # for progress. Everything below is guarded by _cond (a Condition whose lock
# also protects _jobs/_queue/_active); the worker waits on it for new work.
_cond = threading.Condition()
# Tracks the in-flight solve so /cancel can stop it. /cancel is hit two ways: # token -> {"status", "problem", "raw_problem", "max_time", "error"}.
# the user clicking Cancel (a normal fetch, tab stays open) and a closing tab # status is one of: queued, running, done, error, cancelled.
# firing navigator.sendBeacon("/cancel?token=...") — both small requests that _jobs = {}
# pass cleanly through reverse proxies, unlike a dropped upstream socket (which # Tokens waiting to run, in order.
# the proxy keeps alive). Guarded by _active_lock; only one solve runs at a _queue = []
# time, but the lock keeps the token / solver handoff race-free against a
# concurrent /cancel. # The in-flight solve so /cancel can stop it. /cancel is hit two ways: the user
_active_lock = threading.Lock() # clicking a card's Cancel button (a normal fetch, tab stays open) and a closing
# tab firing navigator.sendBeacon("/cancel?token=...") — both small requests
# that pass cleanly through reverse proxies. For a still-queued token /cancel
# just drops it from the queue; for the running token it stops the search.
_active = {"token": None, "solver": None} _active = {"token": None, "solver": None}
# Bound the in-memory job map: keep at most this many terminal (done/error/
# cancelled) entries. Completed solves still live in SQLite, so a pruned "done"
# job degrades gracefully — /job falls back to the DB and still reports done.
MAX_TERMINAL_JOBS = 100
def _prune_jobs():
# Caller holds _cond. Drop the oldest terminal jobs beyond the cap.
terminal = [t for t, j in _jobs.items()
if j["status"] in ("done", "error", "cancelled")]
for t in terminal[:max(0, len(terminal) - MAX_TERMINAL_JOBS)]:
_jobs.pop(t, None)
def _solve_worker():
# Run queued solves one at a time, forever. Started as a daemon in main().
while True:
with _cond:
while not _queue:
_cond.wait()
token = _queue.pop(0)
job = _jobs.get(token)
if job is None or job["status"] != "queued":
continue # cancelled (or vanished) before it ran
job["status"] = "running"
problem = job["problem"]
raw_problem = job["raw_problem"]
max_time = job["max_time"]
_active["token"] = token
_active["solver"] = None
_cond.notify_all() # wake /job_status streams watching this token
def register(s):
with _cond:
if _active["token"] == token:
_active["solver"] = s
try:
sol = solve(problem, max_time_seconds=max_time, solver_sink=register)
sol_dict = solution_to_dict(sol)
# Persist before flipping to "done" so a client that sees "done"
# can always fetch the solution. A cancelled-midway solve returns
# the best plan found so far and is stored like any other.
store_solve(token, raw_problem, sol_dict)
with _cond:
job["status"] = "done"
_cond.notify_all()
except Exception as exc:
with _cond:
job["status"] = "error"
job["error"] = f"{type(exc).__name__}: {exc}"
_cond.notify_all()
finally:
with _cond:
_active["token"] = None
_active["solver"] = None
def _stop_search(solver): def _stop_search(solver):
# StopSearch() exists in OR-Tools 9.x+; degrade gracefully on older builds # StopSearch() exists in OR-Tools 9.x+; degrade gracefully on older builds
@ -135,8 +195,12 @@ class Handler(BaseHTTPRequestHandler):
path = urlsplit(self.path).path path = urlsplit(self.path).path
if path in ("/", "/index.html"): if path in ("/", "/index.html"):
self._send(200, INDEX_HTML, "text/html; charset=utf-8") self._send(200, INDEX_HTML, "text/html; charset=utf-8")
elif path == "/status": elif path == "/job_status":
self._send(200, json.dumps({"solving": _solve_lock.locked()}), no_cache=True) tokens = parse_qs(urlsplit(self.path).query).get("tokens", [""])[0]
self._handle_job_stream(tokens)
elif path.startswith("/job/"):
self._send(200, json.dumps(self._job_state(unquote(path[len("/job/"):]))),
no_cache=True)
elif path.startswith("/solve/"): elif path.startswith("/solve/"):
token = unquote(path[len("/solve/"):]) token = unquote(path[len("/solve/"):])
rec = fetch_solve(token) rec = fetch_solve(token)
@ -147,17 +211,78 @@ class Handler(BaseHTTPRequestHandler):
else: else:
self._send(404, json.dumps({"error": "not found"})) self._send(404, json.dumps({"error": "not found"}))
def _client_gone(self): def _job_state(self, token):
# True once the peer has closed its end. We've already consumed the # Report a queued/running solve's live state, falling back to SQLite for
# request body, so any readable data here is EOF (b"") for a closed tab. # a finished (or evicted-from-memory) one. The client polls this to drive
sock = self.connection # each pending card: queued -> running -> done (render) / error / cancelled.
with _cond:
job = _jobs.get(token)
if job is not None:
status = job["status"]
if status == "queued":
pos = _queue.index(token) if token in _queue else 0
return {"status": "queued", "position": pos}
if status == "running":
return {"status": "running"}
if status == "error":
return {"status": "error", "error": job.get("error")}
if status == "cancelled":
return {"status": "cancelled"}
# status == "done": fall through to load the stored solution.
rec = fetch_solve(token)
if rec is not None:
return {"status": "done", "solution": rec["solution"]}
return {"status": "unknown"}
# Statuses a job can't move on from — once a tracked token reaches one we
# send it a final time and stop watching it.
_TERMINAL = ("done", "error", "cancelled", "unknown")
def _handle_job_stream(self, tokens_csv):
# Server-Sent Events stream for one or more tokens
# (GET /job_status?tokens=t1,t2,…). Emits a `data:` event per token
# whenever its state changes (queued/position -> running -> done/…),
# blocking on _cond between changes rather than busy-polling, and drops
# each token once it's terminal; the stream closes when all are done.
remaining, seen = [], set()
for t in (s.strip() for s in tokens_csv.split(",")):
if t and t not in seen:
seen.add(t)
remaining.append(t)
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "close")
# Tell nginx & friends not to buffer the stream (see reverse-proxy notes).
self.send_header("X-Accel-Buffering", "no")
self.end_headers()
last = {}
try: try:
r, _, _ = select.select([sock], [], [], 0) while remaining:
if r: # Send any token whose state changed since we last reported it,
return sock.recv(1, socket.MSG_PEEK) == b"" # and stop tracking the ones that have reached a terminal state.
except OSError: for token in list(remaining):
return True state = self._job_state(token)
return False payload = json.dumps({"token": token, **state})
if last.get(token) != payload:
last[token] = payload
self.wfile.write(b"data: " + payload.encode("utf-8") + b"\n\n")
self.wfile.flush()
if state["status"] in self._TERMINAL:
remaining.remove(token)
if not remaining:
break
# Block until a job changes state (worker/cancel call notify_all);
# on the periodic timeout send a comment as a keep-alive heartbeat.
with _cond:
notified = _cond.wait(timeout=15)
if not notified:
self.wfile.write(b": ping\n\n")
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, OSError):
return # client closed the EventSource; let the thread end
def do_POST(self): def do_POST(self):
path = urlsplit(self.path) path = urlsplit(self.path)
@ -167,87 +292,53 @@ class Handler(BaseHTTPRequestHandler):
if path.path != "/solve": if path.path != "/solve":
self._send(404, json.dumps({"error": "not found"})) self._send(404, json.dumps({"error": "not found"}))
return return
# Reject immediately if another viewer is already solving. # Validate and enqueue, then return immediately with a queue position.
if not _solve_lock.acquire(blocking=False): # The single background worker runs queued solves one at a time; the
self._send(429, json.dumps({"error": "Another solve is already in progress"})) # client follows progress over /job_status (SSE) or by polling /job/<token>.
return
released = False
token = None
try: try:
length = int(self.headers.get("Content-Length", 0)) length = int(self.headers.get("Content-Length", 0))
payload = json.loads(self.rfile.read(length) or b"{}") payload = json.loads(self.rfile.read(length) or b"{}")
raw_problem = payload.get("problem", {}) raw_problem = payload.get("problem", {})
problem = problem_from_dict(raw_problem) problem = problem_from_dict(raw_problem) # surfaces bad input now
max_time = float(payload.get("max_time_seconds", 30.0)) max_time = float(payload.get("max_time_seconds", 30.0))
token = str(payload.get("token") or "") token = str(payload.get("token") or "")
if not token:
# Mark this token as the in-flight solve so a matching /cancel beacon self._send(400, json.dumps({"error": "missing token"}))
# (sent when the requesting tab closes) can stop it. return
with _active_lock: with _cond:
_active["token"] = token _prune_jobs()
_active["solver"] = None _jobs[token] = {
"status": "queued", "problem": problem,
# Run the (blocking) solve in a worker thread so this thread is free "raw_problem": raw_problem, "max_time": max_time, "error": None,
# to watch the socket. solver_sink hands us the CpSolver so both the }
# local disconnect watcher and /cancel can abort the search. _queue.append(token)
result = {} position = len(_queue) - 1
_cond.notify_all()
def register(s): self._send(202, json.dumps({"status": "queued", "position": position}),
with _active_lock: no_cache=True)
if _active["token"] == token:
_active["solver"] = s
def run():
try:
result["sol"] = solve(
problem, max_time_seconds=max_time, solver_sink=register,
)
except Exception as exc:
result["err"] = exc
worker = threading.Thread(target=run, daemon=True)
worker.start()
while worker.is_alive():
worker.join(0.25)
# Direct connections (no proxy) still get fast release via socket
# EOF; behind a proxy the /cancel beacon is what stops the solve.
if worker.is_alive() and self._client_gone():
with _active_lock:
if _active["solver"] is not None:
_stop_search(_active["solver"])
_solve_lock.release()
released = True
return
if "err" in result:
raise result["err"]
sol_dict = solution_to_dict(result["sol"])
# Persist the completed solve so it can be looked up by its UUID.
# (Cancelled solves take the early return above and aren't stored.)
store_solve(token, raw_problem, sol_dict)
self._send(200, json.dumps(sol_dict))
except Exception as exc: # surface errors to the browser except Exception as exc: # surface errors to the browser
self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"})) self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"}))
finally:
with _active_lock:
if _active["token"] == token:
_active["token"] = None
_active["solver"] = None
if not released:
_solve_lock.release()
def _handle_cancel(self, query): def _handle_cancel(self, query):
# Stop the in-flight solve iff the request's token matches it, so a Cancel # Cancel iff the request's token matches a queued/running solve, so a
# click (or closing tab) can't cancel a *different* viewer's solve. The # Cancel click (or closing tab) can't cancel a *different* viewer's
# running /solve handler then returns normally — with the best plan found # solve. A still-queued token is simply dropped from the queue; the
# so far — and releases the lock. # running token has its search stopped (the worker then stores the best
# plan found so far and the job flips to "done").
token = (query.get("token") or [""])[0] token = (query.get("token") or [""])[0]
stopped = False result = "none"
with _active_lock: with _cond:
if token and token == _active["token"] and _active["solver"] is not None: if token and token in _queue:
_queue.remove(token)
job = _jobs.get(token)
if job is not None:
job["status"] = "cancelled"
result = "dequeued"
_cond.notify_all()
elif token and token == _active["token"] and _active["solver"] is not None:
_stop_search(_active["solver"]) _stop_search(_active["solver"])
stopped = True result = "stopped"
self._send(200, json.dumps({"cancelled": stopped}), no_cache=True) self._send(200, json.dumps({"cancelled": result}), no_cache=True)
def log_message(self, fmt, *args): # quieter console def log_message(self, fmt, *args): # quieter console
pass pass
@ -255,6 +346,7 @@ class Handler(BaseHTTPRequestHandler):
def main(): def main():
init_db() init_db()
threading.Thread(target=_solve_worker, daemon=True).start()
host = os.environ.get("HOST", "127.0.0.1") host = os.environ.get("HOST", "127.0.0.1")
port = int(os.environ.get("PORT", "8000")) port = int(os.environ.get("PORT", "8000"))
server = ThreadingHTTPServer((host, port), Handler) server = ThreadingHTTPServer((host, port), Handler)