Warm a worker before the node's clock starts, and refuse to delete a live flow
A node's timeout now covers its body only: the pool loads the source into the
worker it picked, off the node's budget, so imports that outlast the timeout no
longer make a node impossible to run. Draft checks compile without caching, so
saving does not evict what a busy node is serving calls from. Requests carry an
id the worker echoes and the pool checks, a reply is encoded once, and the
remote-exception cache is bounded.
DELETE /flows/{name} answers 409 while the flow has a running or queued run,
which is what was letting run_node rows outlive their run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StpRc2C6au1WJ1EUU7fsfu
This commit is contained in:
@@ -294,6 +294,13 @@ def _handle(request: dict[str, Any], cache: dict[tuple[str, str], Any]) -> Any:
|
||||
source = request.get("source") or ""
|
||||
digest = hashlib.md5(source.encode()).hexdigest()
|
||||
|
||||
if request["op"] == "compile" and not request.get("keep", True):
|
||||
# A question about a draft, not a load: does this source compile? The
|
||||
# answer is all the editor wants, and keeping it would throw away the
|
||||
# published source this worker is still serving calls from.
|
||||
load_function(flow, node, source)
|
||||
return None
|
||||
|
||||
cached = cache.get((flow, node))
|
||||
if cached is None or cached[0] != digest:
|
||||
cache[(flow, node)] = (digest, load_function(flow, node, source))
|
||||
@@ -305,15 +312,6 @@ def _handle(request: dict[str, Any], cache: dict[tuple[str, str], Any]) -> Any:
|
||||
result = function(**(request.get("kwargs") or {}))
|
||||
if inspect.isgenerator(result):
|
||||
result = _drain(result)
|
||||
try:
|
||||
json.dumps(result)
|
||||
except (TypeError, ValueError):
|
||||
# The typed-message contract only carries JSON, and a node running out
|
||||
# of process is where that stops being a formality.
|
||||
raise ValueError(
|
||||
f"returned {type(result).__name__}, which cannot be sent back as "
|
||||
"JSON — return numbers, strings, booleans, lists or dicts."
|
||||
) from None
|
||||
return result
|
||||
|
||||
|
||||
@@ -360,7 +358,10 @@ def main() -> None:
|
||||
request = json.loads(line)
|
||||
_CALL_ID = str(request.get("call_id") or "")
|
||||
captured = _Capped()
|
||||
response: dict[str, Any] = {"call_id": _CALL_ID}
|
||||
# ``id`` back untouched: the engine matches it against what it sent, so
|
||||
# a pipe that has slipped a call is caught rather than handing one node
|
||||
# another node's answer.
|
||||
response: dict[str, Any] = {"id": request.get("id"), "call_id": _CALL_ID}
|
||||
try:
|
||||
with (
|
||||
contextlib.redirect_stdout(captured),
|
||||
@@ -377,7 +378,28 @@ def main() -> None:
|
||||
"traceback": _node_traceback(exc),
|
||||
}
|
||||
response["logs"] = captured.getvalue()
|
||||
_RPC.write(json.dumps(response) + "\n")
|
||||
try:
|
||||
reply = json.dumps(response)
|
||||
except (TypeError, ValueError, RecursionError):
|
||||
# Encoding the reply is also the check that the node returned
|
||||
# something the typed-message contract can carry — the result is
|
||||
# the only part of this dict a node controls, so it is the only
|
||||
# part that can fail. Doing it once is why there is no separate
|
||||
# dumps of the result above.
|
||||
kind = type(response.pop("result", None)).__name__
|
||||
trouble = (
|
||||
f"returned {kind}, which cannot be sent back as JSON — "
|
||||
"return numbers, strings, booleans, lists or dicts."
|
||||
)
|
||||
response["ok"] = False
|
||||
response["error"] = {
|
||||
"type": "ValueError",
|
||||
"message": trouble,
|
||||
"short": f"ValueError: {trouble}",
|
||||
"traceback": "",
|
||||
}
|
||||
reply = json.dumps(response)
|
||||
_RPC.write(reply + "\n")
|
||||
_RPC.flush()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user