Refuse what a node cannot publish, and stop timing out work that is fine

Four things the python SDK turned up, each fixed where every client sees it.

A key no port declares is now an error rather than a silent drop, on the
return, the yield and the emit alike — the contract the docs already stated.
The SDK reads literal yields at sync time, so a typo fails before anything
runs, and an emission of one fails the call rather than being logged where
nobody looks.

NaN and infinity are refused at the port. JSON cannot spell either, so one
that travelled came back as a 500, a socket frame that stopped the canvas, or
a metric batch the database dropped whole.

An artifact input takes `@run:<id>.<output>` or a bare digest, resolved on the
engine — so the CLI, the run dialog and a python caller mean the same thing,
and a sweep can pass one at all.

Node timeouts are off by default. The clock measured silence, which a training
node is full of, and remote workers had already stopped enforcing it — their
heartbeat reset it. Now a heartbeat proves the agent rather than the node,
ninety seconds of nothing fails the call either way, and the engine touches
work it is still running so a long node is not redelivered at sixty seconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V5bsYGNxcgPs4xXmTPx69
This commit is contained in:
2026-08-25 07:30:14 +02:00
co-authored by Claude Opus 5
parent c33fa404a4
commit 93374a310e
32 changed files with 968 additions and 67 deletions
+119 -2
View File
@@ -28,6 +28,7 @@ from __future__ import annotations
import hashlib
import json
import logging
import math
import os
import socket
import threading
@@ -43,9 +44,9 @@ from sqlalchemy.dialects.sqlite import insert as upsert
from sqlmodel import Session, col, select
from fluksio.core.db import engine as db_engine
from fluksio.flow.artifacts import ArtifactStore, is_reference
from fluksio.flow.artifacts import ArtifactStore, is_reference, valid_digest
from fluksio.flow.controller import FlowController, RunContext
from fluksio.flow.messages import qualify
from fluksio.flow.messages import DType, qualify
from fluksio.flow.pipeline import NodeOutcome, Pipeline
from fluksio.flow.queue import WorkItem, WorkQueue
from fluksio.flow.schemas import FlowDef
@@ -144,6 +145,112 @@ def required_labels(flow: FlowDef) -> list[str]:
)
#: What a run's output is called from outside it: ``@run:<id>.<output>``.
RUN_REF_PREFIX = "@run:"
def resolve_references(
flow: FlowDef, params: dict[str, Any], artifacts: ArtifactStore | None = None
) -> dict[str, Any]:
"""Turn the text spellings of an artifact input into the reference itself.
A python caller hands one run's output straight to the next, because it has
the reference in its hand. A shell does not, and pasting the whole object
is not a command anyone wants to type — so an artifact input also takes
``@run:<id>.<output>``, naming what a run produced, or a bare
``sha256:...`` digest naming the bytes. Resolved here rather than in each
client, so the CLI, the browser and a python caller all mean the same
thing by the same string.
"""
wanted = {
declared.spec.name
for declared in flow.inputs
if declared.spec.dtype is DType.ARTIFACT
}
pending = {
key: value
for key, value in params.items()
if key in wanted and isinstance(value, str)
}
if not pending:
return params
resolved = dict(params)
with Session(db_engine) as session:
for key, text in pending.items():
if text.startswith(RUN_REF_PREFIX):
resolved[key] = _from_run(session, key, text[len(RUN_REF_PREFIX) :])
elif valid_digest(text):
resolved[key] = _from_digest(session, key, text, artifacts)
# Anything else is left alone: the type check names it better than
# a guess about what was meant would.
return resolved
def _from_run(session: Session, key: str, spelling: str) -> dict[str, Any]:
"""``<run id>.<output>`` as the reference that run produced."""
run_id, _, output = spelling.partition(".")
if not run_id or not output:
raise RunRejected(
f"Parameter '{key}': '{RUN_REF_PREFIX}{spelling}' names no output — "
f"write {RUN_REF_PREFIX}<run id>.<output>"
)
run = session.get(Run, run_id)
if run is None:
raise RunRejected(f"Parameter '{key}': there is no run '{run_id}'")
# The run's own result first: that is the reference as its producer made
# it, file name and all. The rows are the fallback, and they carry the
# message name instead — which loads the same bytes either way.
candidate = (run.result or {}).get(output)
if is_reference(candidate):
return dict(candidate)
rows = session.exec(
select(RunArtifact).where(col(RunArtifact.run_id) == run_id)
).all()
for row in rows:
if output in (row.name, row.name.rsplit(".", 1)[-1]):
return {
"digest": row.digest,
"size": row.size,
"media_type": row.media_type or "application/octet-stream",
"name": row.name,
}
known = ", ".join(sorted(row.name for row in rows)) or "none"
raise RunRejected(
f"Parameter '{key}': run '{run_id}' has no artifact '{output}' "
f"(it made: {known})"
)
def _from_digest(
session: Session, key: str, digest: str, artifacts: ArtifactStore | None
) -> dict[str, Any]:
"""A bare digest as a reference, with the size the store needs."""
row = session.exec(
select(RunArtifact)
.where(col(RunArtifact.digest) == digest)
.order_by(col(RunArtifact.run_id).desc())
).first()
if row is None:
raise RunRejected(
f"Parameter '{key}': no run has produced '{digest}', so there is "
"nothing here under that digest"
)
if artifacts is not None and artifacts.path(digest) is None:
raise RunRejected(
f"Parameter '{key}': '{digest}' is known but its bytes are gone "
"from this installation's store"
)
return {
"digest": row.digest,
"size": row.size,
"media_type": row.media_type or "application/octet-stream",
"name": row.name,
}
def seed_values(
flow: FlowDef, params: dict[str, Any], seed: int | None = None
) -> dict[str, Any]:
@@ -230,6 +337,11 @@ class MetricSink:
# A checkpoint or a record is on the run some other way —
# as an artifact, or as its result. Only numbers are series.
continue
if not math.isfinite(value):
# The ports refuse these, so one here came from somewhere
# that does not go through them. A column that cannot hold
# it would take the whole batch down with it.
continue
step = self._steps.get(name, -1) + 1
self._steps[name] = step
row = RunMetric(
@@ -368,6 +480,7 @@ class RunService:
) -> None:
self.controller = controller
self.queue = queue
self._artifacts = artifacts
self._cache = RunCache(artifacts)
# Without one, a run gets a private in-memory state — which is exactly
# the isolation it wants, minus surviving the process.
@@ -433,6 +546,10 @@ class RunService:
if issues:
raise RunRejected(" ".join(issues))
params = params or {}
# Resolved before it is stored, so what the run records is the same
# reference a python caller would have passed and every later reader —
# the digest, the cache, the run detail — sees one spelling.
params = resolve_references(flow, params, self._artifacts)
# Checked here rather than in the driver: a caller who mistyped a
# parameter should be told now, not by a run that fails in a minute.
seed_values(flow, params, seed)